@logux/server 0.13.1 → 0.15.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.
@@ -1,4 +1,5 @@
1
1
  import { TestTime } from '@logux/core'
2
+ import { createServer } from 'node:http'
2
3
 
3
4
  import { BaseServer } from '../base-server/index.js'
4
5
  import { createReporter } from '../create-reporter/index.js'
@@ -13,8 +14,8 @@ export class TestServer extends BaseServer {
13
14
  opts.time.lastId += 1
14
15
  super({
15
16
  id: `${opts.time.lastId}`,
16
- subprotocol: '0.0.0',
17
- supports: '0.0.0',
17
+ minSubprotocol: 0,
18
+ subprotocol: 0,
18
19
  ...opts
19
20
  })
20
21
  if (opts.logger) {
@@ -31,8 +32,10 @@ export class TestServer extends BaseServer {
31
32
  if (opts.auth !== false) this.auth(() => true)
32
33
  this.testUsers = {}
33
34
 
34
- this.on('fatal', () => {
35
- this.destroy()
35
+ this.fetch = this.fetch.bind(this)
36
+
37
+ this.on('fatal', async () => {
38
+ await this.destroy()
36
39
  })
37
40
  }
38
41
 
@@ -69,7 +72,8 @@ export class TestServer extends BaseServer {
69
72
  if (e.message === 'Server undid action') {
70
73
  if (e.action.reason !== reason) {
71
74
  throw new Error(
72
- `Undo was with ${e.action.reason} reason, not ${reason}`
75
+ `Undo was with ${e.action.reason} reason, not ${reason}`,
76
+ { cause: e }
73
77
  )
74
78
  }
75
79
  } else {
@@ -88,4 +92,20 @@ export class TestServer extends BaseServer {
88
92
  }
89
93
  }
90
94
  }
95
+
96
+ async fetch(path, init) {
97
+ let server = createServer(async (req, res) => {
98
+ await this.processHttp(req, res)
99
+ server.close()
100
+ })
101
+ await new Promise(resolve => {
102
+ server.listen(0, resolve)
103
+ })
104
+ let { port } = server.address()
105
+ return fetch(`http://localhost:${port}${path}`, init)
106
+ }
107
+
108
+ isBruteforce() {
109
+ return false
110
+ }
91
111
  }
@@ -0,0 +1,28 @@
1
+ const PARAM = /:(\w+)/g
2
+ const SPECIAL = /[.*+?^${}()|[\]\\]/g
3
+
4
+ function escape(text) {
5
+ return text.replace(SPECIAL, '\\$&')
6
+ }
7
+
8
+ export function createPattern(pattern) {
9
+ let names = []
10
+ let source = ''
11
+ let last = 0
12
+ for (let param of pattern.matchAll(PARAM)) {
13
+ source += escape(pattern.slice(last, param.index)) + '([^/]+)'
14
+ names.push(param[1])
15
+ last = param.index + param[0].length
16
+ }
17
+ let regexp = new RegExp('^' + source + escape(pattern.slice(last)) + '$')
18
+
19
+ return url => {
20
+ let match = regexp.exec(url)
21
+ if (!match) return null
22
+ let params = {}
23
+ for (let i = 0; i < names.length; i++) {
24
+ if (names[i] !== '__proto__') params[names[i]] = match[i + 1]
25
+ }
26
+ return params
27
+ }
28
+ }
@@ -1,325 +0,0 @@
1
- import { LoguxError } from '@logux/core'
2
- import JSONStream from 'JSONStream'
3
- import { nanoid } from 'nanoid'
4
- import http from 'node:http'
5
- import https from 'node:https'
6
-
7
- const VERSION = 4
8
-
9
- const RESEND_KEYS = [
10
- 'channels',
11
- 'channel',
12
- 'nodes',
13
- 'node',
14
- 'clients',
15
- 'client',
16
- 'users',
17
- 'user'
18
- ]
19
-
20
- function send(backend, command, events) {
21
- let body = JSON.stringify({
22
- commands: [command],
23
- secret: backend.secret,
24
- version: VERSION
25
- })
26
- let protocol = backend.protocol === 'https:' ? https : http
27
- let req = protocol.request(
28
- {
29
- headers: {
30
- 'Content-Length': Buffer.byteLength(body),
31
- 'Content-Type': 'application/json'
32
- },
33
- host: backend.hostname,
34
- method: 'POST',
35
- path: backend.pathname + backend.search,
36
- port: backend.port
37
- },
38
- res => {
39
- if (res.statusCode < 200 || res.statusCode > 299) {
40
- events.error(new Error(`Backend responded with ${res.statusCode} code`))
41
- } else {
42
- let data = false
43
- res
44
- .pipe(JSONStream.parse('*'))
45
- .on('data', answer => {
46
- if (
47
- typeof answer !== 'object' ||
48
- typeof answer.answer !== 'string'
49
- ) {
50
- events.error(new Error('Wrong back-end answer'))
51
- } else if (answer.answer === 'error') {
52
- let err = new Error('Error on back-end server')
53
- err.stack = answer.details
54
- events.error(err)
55
- } else if (events.filter(answer)) {
56
- if (!events[answer.answer]) {
57
- events.error(new Error('Unknown back-end answer'))
58
- } else {
59
- data = true
60
- events[answer.answer](answer)
61
- }
62
- }
63
- })
64
- .on('error', err => {
65
- events.error(err)
66
- })
67
- .on('end', () => {
68
- if (!data) {
69
- events.error(new Error('Empty back-end answer'))
70
- } else if (events.end) {
71
- events.end()
72
- }
73
- })
74
- }
75
- }
76
- )
77
- req.on('error', err => {
78
- events.error(err)
79
- })
80
- req.end(body)
81
- }
82
-
83
- export function bindBackendProxy(app) {
84
- if (app.options.controlSecret) {
85
- app.controls['POST /'] = {
86
- command({ action, meta }, req) {
87
- if (!app.types[action.type] && !app.getRegexProcessor(action.type)) {
88
- meta.status = 'processed'
89
- }
90
- meta.backend = req.connection.remoteAddress
91
- return app.log.add(action, meta)
92
- },
93
- isValid({ action, command, meta }) {
94
- return (
95
- command === 'action' &&
96
- typeof action === 'object' &&
97
- typeof action.type === 'string' &&
98
- typeof meta === 'object'
99
- )
100
- }
101
- }
102
- }
103
-
104
- if (!app.options.backend) return
105
- if (!app.options.controlSecret) {
106
- let e = new Error('`backend` requires `controlSecret` option')
107
- e.code = 'LOGUX_NO_CONTROL_SECRET'
108
- throw e
109
- }
110
-
111
- let backend = new URL(app.options.backend)
112
- backend.secret = app.options.controlSecret
113
-
114
- let resending = new Map()
115
- let accessing = new Map()
116
- let processing = new Map()
117
- let actions = new Map()
118
-
119
- function sendAction(action, meta, headers) {
120
- let resendResolve
121
- if (action.type !== 'logux/subscribe') {
122
- resending.set(
123
- meta.id,
124
- new Promise(resolve => {
125
- resendResolve = resolve
126
- })
127
- )
128
- }
129
- let accessReject, accessResolve
130
- accessing.set(
131
- meta.id,
132
- new Promise((resolve, reject) => {
133
- accessResolve = resolve
134
- accessReject = reject
135
- })
136
- )
137
- let processReject, processResolve
138
- processing.set(
139
- meta.id,
140
- new Promise((resolve, reject) => {
141
- processResolve = resolve
142
- processReject = reject
143
- })
144
- )
145
-
146
- let checked = false
147
- let processed = false
148
- let error = false
149
- function currentReject(e) {
150
- if (resendResolve) resendResolve()
151
- if (checked) {
152
- processReject(e)
153
- } else {
154
- accessReject(e)
155
- }
156
- }
157
-
158
- let start = Date.now()
159
- app.emitter.emit('backendSent', action, meta)
160
- send(
161
- backend,
162
- { action, command: 'action', headers, meta },
163
- {
164
- action(data) {
165
- let promise = app.log.add(data.action, {
166
- status: 'processed',
167
- ...data.meta
168
- })
169
- if (actions.has(meta.id)) {
170
- actions.get(meta.id).push(promise)
171
- } else {
172
- actions.set(meta.id, [promise])
173
- }
174
- },
175
- approved() {
176
- if (resendResolve) resendResolve()
177
- app.emitter.emit('backendGranted', action, meta, Date.now() - start)
178
- checked = true
179
- accessResolve(true)
180
- },
181
- end() {
182
- if (!error && (!checked || !processed)) {
183
- currentReject(new Error('Back-end do not send required answers'))
184
- }
185
- },
186
- error(e) {
187
- error = true
188
- currentReject(e)
189
- },
190
- filter({ id }) {
191
- return id === meta.id
192
- },
193
- forbidden() {
194
- if (resendResolve) resendResolve()
195
- error = true
196
- accessResolve(false)
197
- },
198
- async processed() {
199
- if (!checked) {
200
- error = true
201
- accessReject(new Error('Processed answer was sent before access'))
202
- } else {
203
- processed = true
204
- await Promise.all(actions.get(meta.id) || [])
205
- app.emitter.emit(
206
- 'backendProcessed',
207
- action,
208
- meta,
209
- Date.now() - start
210
- )
211
- processResolve()
212
- }
213
- },
214
- resend(answer) {
215
- if (checked) {
216
- error = true
217
- currentReject(new Error('Resend answer was sent after access'))
218
- } else if (action.type === 'logux/subscribe') {
219
- error = true
220
- accessReject(new Error('Resend can be called on subscription'))
221
- } else {
222
- let resend = {}
223
- for (let key of RESEND_KEYS) {
224
- if (typeof answer[key] !== 'undefined') {
225
- resend[key] = answer[key]
226
- }
227
- }
228
- resendResolve(resend)
229
- }
230
- },
231
- unknownAction() {
232
- resendResolve()
233
- error = true
234
- app.unknownType(action, meta)
235
- accessResolve(false)
236
- },
237
- unknownChannel() {
238
- error = true
239
- app.wrongChannel(action, meta)
240
- accessResolve(false)
241
- }
242
- }
243
- )
244
- }
245
-
246
- app.auth(
247
- ({ client, cookie, headers, token, userId }) =>
248
- new Promise((resolve, reject) => {
249
- let random = nanoid()
250
- send(
251
- backend,
252
- {
253
- authId: random,
254
- command: 'auth',
255
- cookie,
256
- headers,
257
- subprotocol: client.node.remoteSubprotocol,
258
- token,
259
- userId
260
- },
261
- {
262
- authenticated({ subprotocol }) {
263
- if (subprotocol) {
264
- app.options.subprotocol = subprotocol
265
- client.node.options.subprotocol = subprotocol
266
- }
267
- resolve(true)
268
- },
269
- denied() {
270
- resolve(false)
271
- },
272
- error(e) {
273
- reject(e)
274
- },
275
- filter({ authId }) {
276
- return random === authId
277
- },
278
- wrongSubprotocol({ supported }) {
279
- reject(
280
- new LoguxError('wrong-subprotocol', {
281
- supported,
282
- used: client.node.remoteSubprotocol
283
- })
284
- )
285
- }
286
- }
287
- )
288
- })
289
- )
290
- app.otherType({
291
- access(ctx, action, meta) {
292
- sendAction(action, meta, ctx.headers)
293
- return accessing.get(meta.id)
294
- },
295
- finally(ctx, action, meta) {
296
- actions.delete(meta.id)
297
- resending.delete(meta.id)
298
- accessing.delete(meta.id)
299
- processing.delete(meta.id)
300
- },
301
- process(ctx, action, meta) {
302
- return processing.get(meta.id)
303
- },
304
- resend(ctx, action, meta) {
305
- if (!resending.has(meta.id)) {
306
- sendAction(action, meta, ctx.headers)
307
- }
308
- return resending.get(meta.id)
309
- }
310
- })
311
- app.otherChannel({
312
- access(ctx, action, meta) {
313
- sendAction(action, meta, ctx.headers)
314
- return accessing.get(meta.id)
315
- },
316
- finally(ctx, action, meta) {
317
- actions.delete(meta.id)
318
- accessing.delete(meta.id)
319
- processing.delete(meta.id)
320
- },
321
- load(ctx, action, meta) {
322
- return processing.get(meta.id)
323
- }
324
- })
325
- }
@@ -1,137 +0,0 @@
1
- import { Netmask } from 'netmask'
2
-
3
- const MAX_VERSION = 4
4
- const MIN_VERSION = 4
5
- const NO_SECRET =
6
- 'Set LOGUX_CONTROL_SECRET environment variable for Logux ' +
7
- 'to have access to this page.\n' +
8
- 'Run `npx nanoid` to generate secure secret.'
9
-
10
- function isValidBody(body) {
11
- if (typeof body !== 'object') return false
12
- if (typeof body.version !== 'number') return false
13
- if (typeof body.secret !== 'string') return false
14
- if (!Array.isArray(body.commands)) return false
15
- if (body.commands.some(i => typeof i !== 'object')) return false
16
- return true
17
- }
18
-
19
- export function bindControlServer(app, custom) {
20
- if (app.options.disableHttpServer) {
21
- if (app.options.controlSecret) {
22
- let err = new Error(
23
- '`controlSecret` can not be set together ' +
24
- 'with `disableHttpServer` option'
25
- )
26
- err.logux = true
27
- throw err
28
- }
29
- return
30
- }
31
-
32
- let masks = app.options.controlMask.split(/,\s*/).map(i => new Netmask(i))
33
- app.httpServer.on('request', async (req, res) => {
34
- let urlString = req.url
35
- if (/^\/\w+%3F/.test(urlString)) urlString = decodeURIComponent(urlString)
36
- let reqUrl = new URL(urlString, 'http://localhost')
37
- let rule = app.controls[req.method + ' ' + reqUrl.pathname]
38
-
39
- if (!rule) {
40
- if (custom) {
41
- custom(req, res)
42
- } else {
43
- let accepts = Object.keys(app.controls)
44
- if (accepts.some(i => i.endsWith(' ' + reqUrl.pathname))) {
45
- res.statusCode = 405
46
- res.end('Wrong method')
47
- } else {
48
- res.statusCode = 404
49
- res.end('Wrong path')
50
- }
51
- }
52
- } else {
53
- let ipAddress = req.connection.remoteAddress
54
- if (!(req.method === 'GET' && rule.safe)) {
55
- if (masks.every(i => !i.contains(ipAddress))) {
56
- app.emitter.emit('report', 'wrongControlIp', {
57
- ipAddress,
58
- mask: app.options.controlMask
59
- })
60
- res.statusCode = 403
61
- res.end('IP address is not in LOGUX_CONTROL_MASK/controlMask')
62
- return
63
- }
64
- }
65
- if (req.method !== 'GET') {
66
- let json = ''
67
- req.on('data', chunk => {
68
- json += chunk
69
- })
70
- req.on('end', async () => {
71
- let body
72
- try {
73
- body = JSON.parse(json)
74
- } catch {
75
- res.statusCode = 400
76
- res.end('Wrong format')
77
- return
78
- }
79
- if (!isValidBody(body)) {
80
- res.statusCode = 400
81
- res.end('Wrong body')
82
- } else if (body.version < MIN_VERSION || body.version > MAX_VERSION) {
83
- res.statusCode = 400
84
- res.end('Back-end protocol version is not supported')
85
- } else if (app.isBruteforce(req.connection.remoteAddress)) {
86
- res.statusCode = 429
87
- res.end('Too many wrong secret attempts')
88
- } else if (body.secret !== app.options.controlSecret) {
89
- app.rememberBadAuth(req.connection.remoteAddress)
90
- app.emitter.emit('report', 'wrongControlSecret', {
91
- ipAddress,
92
- wrongSecret: body.secret
93
- })
94
- res.statusCode = 403
95
- res.end('Wrong secret')
96
- } else {
97
- for (let i of body.commands) {
98
- if (!rule.isValid(i)) {
99
- res.statusCode = 400
100
- res.end('Wrong command')
101
- return
102
- }
103
- }
104
- await Promise.all(body.commands.map(i => rule.command(i, req)))
105
- res.end()
106
- }
107
- })
108
- } else {
109
- if (!rule.safe) {
110
- if (!app.options.controlSecret) {
111
- res.statusCode = 500
112
- res.end(NO_SECRET)
113
- return
114
- } else if (app.isBruteforce(req.connection.remoteAddress)) {
115
- res.statusCode = 429
116
- res.end('Too many wrong secret attempts')
117
- return
118
- } else if (reqUrl.search !== '?' + app.options.controlSecret) {
119
- app.rememberBadAuth(req.connection.remoteAddress)
120
- app.emitter.emit('report', 'wrongControlSecret', {
121
- ipAddress,
122
- wrongSecret: reqUrl.search.slice(1)
123
- })
124
- res.statusCode = 403
125
- res.end('Wrong secret')
126
- return
127
- }
128
- }
129
- let answer = await rule.request(req)
130
- for (let name in answer.headers) {
131
- res.setHeader(name, answer.headers[name])
132
- }
133
- res.end(answer.body)
134
- }
135
- }
136
- })
137
- }
@@ -1,134 +0,0 @@
1
- import { spy } from 'nanospy'
2
- import { expect, it } from 'vitest'
3
-
4
- import { del, get, patch, post, put, request, ResponseError } from '../index.js'
5
-
6
- it('has error', () => {
7
- let error = new ResponseError(
8
- 403,
9
- '/a',
10
- { body: 'body', method: 'GET' },
11
- 'answer'
12
- )
13
- expect(error.name).toEqual('ResponseError')
14
- expect(error.statusCode).toEqual(403)
15
- expect(error.message).toEqual('403 response on GET /a {"body":"body"} answer')
16
- })
17
-
18
- it('throws an error on non-2xx response', async () => {
19
- async function fetch() {
20
- return {
21
- status: 403,
22
- async text() {
23
- return 'answer'
24
- }
25
- }
26
- }
27
- await expect(
28
- request('/a', { body: 'body', method: 'GET' }, fetch)
29
- ).rejects.toThrow(
30
- new ResponseError(403, '/a', { body: 'body', method: 'GET' }, 'answer')
31
- )
32
- })
33
-
34
- it('parses JSON body', async () => {
35
- async function fetch() {
36
- return {
37
- async json() {
38
- return { answer: '1' }
39
- },
40
- status: 200
41
- }
42
- }
43
- expect(await request('/a', { method: 'GET' }, fetch)).toEqual({ answer: '1' })
44
- })
45
-
46
- it('has shortcut for GET', async () => {
47
- let fetch = spy(async () => ({
48
- async json() {
49
- return { answer: '1' }
50
- },
51
- status: 200
52
- }))
53
-
54
- expect(await get('/a', { headers: { a: 1 } }, fetch)).toEqual({ answer: '1' })
55
- expect(fetch.calls).toEqual([
56
- [
57
- '/a',
58
- {
59
- headers: { a: 1 },
60
- method: 'GET'
61
- }
62
- ]
63
- ])
64
- })
65
-
66
- it('has shortcut for POST', async () => {
67
- let fetch = spy(async () => ({ status: 204 }))
68
- await post('/a', { headers: { a: 1 } }, fetch)
69
- expect(fetch.calls).toEqual([
70
- [
71
- '/a',
72
- {
73
- headers: { a: 1 },
74
- method: 'POST'
75
- }
76
- ]
77
- ])
78
- })
79
-
80
- it('has shortcut for PUT', async () => {
81
- let fetch = spy(async () => ({ status: 204 }))
82
- await put('/a', { headers: { a: 1 } }, fetch)
83
- expect(fetch.calls).toEqual([
84
- [
85
- '/a',
86
- {
87
- headers: { a: 1 },
88
- method: 'PUT'
89
- }
90
- ]
91
- ])
92
- })
93
-
94
- it('has shortcut for PATCH', async () => {
95
- let fetch = spy(async () => ({ status: 204 }))
96
- await patch('/a', { headers: { a: 1 } }, fetch)
97
- expect(fetch.calls).toEqual([
98
- [
99
- '/a',
100
- {
101
- headers: { a: 1 },
102
- method: 'PATCH'
103
- }
104
- ]
105
- ])
106
- })
107
-
108
- it('has shortcut for DELETE', async () => {
109
- let fetch = spy(async () => ({ status: 204 }))
110
- await del('/a', { headers: { a: 1 } }, fetch)
111
- expect(fetch.calls).toEqual([
112
- [
113
- '/a',
114
- {
115
- headers: { a: 1 },
116
- method: 'DELETE'
117
- }
118
- ]
119
- ])
120
- })
121
-
122
- it('works without option object', async () => {
123
- async function fetch() {
124
- return {
125
- status: 404,
126
- async text() {
127
- return 'answer'
128
- }
129
- }
130
- }
131
- await expect(get('/a', undefined, fetch)).rejects.toThrow(
132
- new ResponseError(404, '/a', { method: 'GET' }, 'answer')
133
- )
134
- })