@logux/server 0.13.0 → 0.14.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,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,136 +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 with `disableHttpServer` option'
24
- )
25
- err.logux = true
26
- throw err
27
- }
28
- return
29
- }
30
-
31
- let masks = app.options.controlMask.split(/,\s*/).map(i => new Netmask(i))
32
- app.httpServer.on('request', async (req, res) => {
33
- let urlString = req.url
34
- if (/^\/\w+%3F/.test(urlString)) urlString = decodeURIComponent(urlString)
35
- let reqUrl = new URL(urlString, 'http://localhost')
36
- let rule = app.controls[req.method + ' ' + reqUrl.pathname]
37
-
38
- if (!rule) {
39
- if (custom) {
40
- custom(req, res)
41
- } else {
42
- let accepts = Object.keys(app.controls)
43
- if (accepts.some(i => i.endsWith(' ' + reqUrl.pathname))) {
44
- res.statusCode = 405
45
- res.end('Wrong method')
46
- } else {
47
- res.statusCode = 404
48
- res.end('Wrong path')
49
- }
50
- }
51
- } else {
52
- let ipAddress = req.connection.remoteAddress
53
- if (!(req.method === 'GET' && rule.safe)) {
54
- if (masks.every(i => !i.contains(ipAddress))) {
55
- app.emitter.emit('report', 'wrongControlIp', {
56
- ipAddress,
57
- mask: app.options.controlMask
58
- })
59
- res.statusCode = 403
60
- res.end('IP address is not in LOGUX_CONTROL_MASK/controlMask')
61
- return
62
- }
63
- }
64
- if (req.method !== 'GET') {
65
- let json = ''
66
- req.on('data', chunk => {
67
- json += chunk
68
- })
69
- req.on('end', async () => {
70
- let body
71
- try {
72
- body = JSON.parse(json)
73
- } catch {
74
- res.statusCode = 400
75
- res.end('Wrong format')
76
- return
77
- }
78
- if (!isValidBody(body)) {
79
- res.statusCode = 400
80
- res.end('Wrong body')
81
- } else if (body.version < MIN_VERSION || body.version > MAX_VERSION) {
82
- res.statusCode = 400
83
- res.end('Back-end protocol version is not supported')
84
- } else if (app.isBruteforce(req.connection.remoteAddress)) {
85
- res.statusCode = 429
86
- res.end('Too many wrong secret attempts')
87
- } else if (body.secret !== app.options.controlSecret) {
88
- app.rememberBadAuth(req.connection.remoteAddress)
89
- app.emitter.emit('report', 'wrongControlSecret', {
90
- ipAddress,
91
- wrongSecret: body.secret
92
- })
93
- res.statusCode = 403
94
- res.end('Wrong secret')
95
- } else {
96
- for (let i of body.commands) {
97
- if (!rule.isValid(i)) {
98
- res.statusCode = 400
99
- res.end('Wrong command')
100
- return
101
- }
102
- }
103
- await Promise.all(body.commands.map(i => rule.command(i, req)))
104
- res.end()
105
- }
106
- })
107
- } else {
108
- if (!rule.safe) {
109
- if (!app.options.controlSecret) {
110
- res.statusCode = 500
111
- res.end(NO_SECRET)
112
- return
113
- } else if (app.isBruteforce(req.connection.remoteAddress)) {
114
- res.statusCode = 429
115
- res.end('Too many wrong secret attempts')
116
- return
117
- } else if (reqUrl.search !== '?' + app.options.controlSecret) {
118
- app.rememberBadAuth(req.connection.remoteAddress)
119
- app.emitter.emit('report', 'wrongControlSecret', {
120
- ipAddress,
121
- wrongSecret: reqUrl.search.slice(1)
122
- })
123
- res.statusCode = 403
124
- res.end('Wrong secret')
125
- return
126
- }
127
- }
128
- let answer = await rule.request(req)
129
- for (let name in answer.headers) {
130
- res.setHeader(name, answer.headers[name])
131
- }
132
- res.end(answer.body)
133
- }
134
- }
135
- })
136
- }
@@ -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
- })
File without changes