@atproto/xrpc-server 0.6.3 → 0.7.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/src/util.ts CHANGED
@@ -1,7 +1,6 @@
1
- import assert from 'assert'
2
- import { Readable, Transform } from 'stream'
3
- import { IncomingMessage } from 'http'
4
- import { createDeflate, createGunzip } from 'zlib'
1
+ import assert from 'node:assert'
2
+ import { Duplex, pipeline, Readable } from 'node:stream'
3
+ import { IncomingMessage } from 'node:http'
5
4
  import express from 'express'
6
5
  import mime from 'mime-types'
7
6
  import {
@@ -11,7 +10,9 @@ import {
11
10
  LexXrpcQuery,
12
11
  LexXrpcSubscription,
13
12
  } from '@atproto/lexicon'
14
- import { forwardStreamErrors, MaxSizeChecker } from '@atproto/common'
13
+ import { createDecoders, MaxSizeChecker } from '@atproto/common'
14
+ import { ResponseType } from '@atproto/xrpc'
15
+
15
16
  import {
16
17
  UndecodedParams,
17
18
  Params,
@@ -155,7 +156,7 @@ export function validateOutput(
155
156
  def: LexXrpcProcedure | LexXrpcQuery,
156
157
  output: HandlerSuccess | undefined,
157
158
  lexicons: Lexicons,
158
- ): HandlerSuccess | undefined {
159
+ ): void {
159
160
  // initial validation
160
161
  if (output) {
161
162
  handlerSuccess.parse(output)
@@ -195,8 +196,6 @@ export function validateOutput(
195
196
  throw new InternalServerError(e instanceof Error ? e.message : String(e))
196
197
  }
197
198
  }
198
-
199
- return output
200
199
  }
201
200
 
202
201
  export function normalizeMime(v: string) {
@@ -237,40 +236,52 @@ function decodeBodyStream(
237
236
  req: express.Request,
238
237
  maxSize: number | undefined,
239
238
  ): Readable {
240
- let stream: Readable = req
241
239
  const contentEncoding = req.headers['content-encoding']
242
240
  const contentLength = req.headers['content-length']
243
241
 
242
+ const contentLengthParsed = contentLength
243
+ ? parseInt(contentLength, 10)
244
+ : undefined
245
+
246
+ if (Number.isNaN(contentLengthParsed)) {
247
+ throw new XRPCError(ResponseType.InvalidRequest, 'invalid content-length')
248
+ }
249
+
244
250
  if (
245
251
  maxSize !== undefined &&
246
- contentLength &&
247
- parseInt(contentLength, 10) > maxSize
252
+ contentLengthParsed !== undefined &&
253
+ contentLengthParsed > maxSize
248
254
  ) {
249
- throw new XRPCError(413, 'request entity too large')
250
- }
251
-
252
- let decoder: Transform | undefined
253
- if (contentEncoding === 'gzip') {
254
- decoder = createGunzip()
255
- } else if (contentEncoding === 'deflate') {
256
- decoder = createDeflate()
255
+ throw new XRPCError(
256
+ ResponseType.PayloadTooLarge,
257
+ 'request entity too large',
258
+ )
257
259
  }
258
260
 
259
- if (decoder) {
260
- forwardStreamErrors(stream, decoder)
261
- stream = stream.pipe(decoder)
261
+ let transforms: Duplex[]
262
+ try {
263
+ transforms = createDecoders(contentEncoding)
264
+ } catch (cause) {
265
+ throw new XRPCError(
266
+ ResponseType.UnsupportedMediaType,
267
+ 'unsupported content-encoding',
268
+ undefined,
269
+ { cause },
270
+ )
262
271
  }
263
272
 
264
273
  if (maxSize !== undefined) {
265
274
  const maxSizeChecker = new MaxSizeChecker(
266
275
  maxSize,
267
- () => new XRPCError(413, 'request entity too large'),
276
+ () =>
277
+ new XRPCError(ResponseType.PayloadTooLarge, 'request entity too large'),
268
278
  )
269
- forwardStreamErrors(stream, maxSizeChecker)
270
- stream = stream.pipe(maxSizeChecker)
279
+ transforms.push(maxSizeChecker)
271
280
  }
272
281
 
273
- return stream
282
+ return transforms.length > 0
283
+ ? (pipeline([req, ...transforms], () => {}) as Duplex)
284
+ : req
274
285
  }
275
286
 
276
287
  export function serverTimingHeader(timings: ServerTiming[]) {
@@ -308,11 +319,70 @@ export interface ServerTiming {
308
319
  description?: string
309
320
  }
310
321
 
311
- export const parseReqNsid = (
312
- req: express.Request | IncomingMessage,
313
- ): string => {
314
- const originalUrl =
315
- ('originalUrl' in req && req.originalUrl) || req.url || '/'
316
- const nsid = originalUrl.split('?')[0].replace('/xrpc/', '')
317
- return nsid.endsWith('/') ? nsid.slice(0, -1) : nsid // trim trailing slash
322
+ export const parseReqNsid = (req: express.Request | IncomingMessage) =>
323
+ parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
324
+
325
+ /**
326
+ * Validates and extracts the nsid from an xrpc path
327
+ */
328
+ export const parseUrlNsid = (url: string): string => {
329
+ // /!\ Hot path
330
+
331
+ if (
332
+ // Ordered by likelihood of failure
333
+ url.length <= 6 ||
334
+ url[5] !== '/' ||
335
+ url[4] !== 'c' ||
336
+ url[3] !== 'p' ||
337
+ url[2] !== 'r' ||
338
+ url[1] !== 'x' ||
339
+ url[0] !== '/'
340
+ ) {
341
+ throw new InvalidRequestError('invalid xrpc path')
342
+ }
343
+
344
+ const startOfNsid = 6
345
+
346
+ let curr = startOfNsid
347
+ let char: number
348
+ let alphaNumRequired = true
349
+ for (; curr < url.length; curr++) {
350
+ char = url.charCodeAt(curr)
351
+ if (
352
+ (char >= 48 && char <= 57) || // 0-9
353
+ (char >= 65 && char <= 90) || // A-Z
354
+ (char >= 97 && char <= 122) // a-z
355
+ ) {
356
+ alphaNumRequired = false
357
+ } else if (char === 45 /* "-" */ || char === 46 /* "." */) {
358
+ if (alphaNumRequired) {
359
+ throw new InvalidRequestError('invalid xrpc path')
360
+ }
361
+ alphaNumRequired = true
362
+ } else if (char === 47 /* "/" */) {
363
+ // Allow trailing slash (next char is either EOS or "?")
364
+ if (curr === url.length - 1 || url.charCodeAt(curr + 1) === 63) {
365
+ break
366
+ }
367
+ throw new InvalidRequestError('invalid xrpc path')
368
+ } else if (char === 63 /* "?"" */) {
369
+ break
370
+ } else {
371
+ throw new InvalidRequestError('invalid xrpc path')
372
+ }
373
+ }
374
+
375
+ // last char was one of: '-', '.', '/'
376
+ if (alphaNumRequired) {
377
+ throw new InvalidRequestError('invalid xrpc path')
378
+ }
379
+
380
+ // A domain name consists of minimum two characters
381
+ if (curr - startOfNsid < 2) {
382
+ throw new InvalidRequestError('invalid xrpc path')
383
+ }
384
+
385
+ // @TODO is there a max ?
386
+
387
+ return url.slice(startOfNsid, curr)
318
388
  }
@@ -1,9 +1,9 @@
1
1
  import * as http from 'http'
2
2
  import { Readable } from 'stream'
3
- import { gzipSync } from 'zlib'
3
+ import { brotliCompressSync, deflateSync, gzipSync } from 'zlib'
4
4
  import getPort from 'get-port'
5
5
  import { LexiconDoc } from '@atproto/lexicon'
6
- import { XrpcClient } from '@atproto/xrpc'
6
+ import { ResponseType, XrpcClient } from '@atproto/xrpc'
7
7
  import { cidForCbor } from '@atproto/common'
8
8
  import { randomBytes } from '@atproto/crypto'
9
9
  import { createServer, closeServer } from './_util'
@@ -91,13 +91,25 @@ const BLOB_LIMIT = 5000
91
91
  async function consumeInput(
92
92
  input: Readable | string | object,
93
93
  ): Promise<Buffer> {
94
- if (typeof input === 'string') return Buffer.from(input)
94
+ if (Buffer.isBuffer(input)) {
95
+ return input
96
+ }
97
+ if (typeof input === 'string') {
98
+ return Buffer.from(input)
99
+ }
95
100
  if (input instanceof Readable) {
96
- const buffers: Buffer[] = []
97
- for await (const data of input) {
98
- buffers.push(data)
101
+ try {
102
+ return Buffer.concat(await input.toArray())
103
+ } catch (err) {
104
+ if (err instanceof xrpcServer.XRPCError) {
105
+ throw err
106
+ } else {
107
+ throw new xrpcServer.XRPCError(
108
+ ResponseType.InvalidRequest,
109
+ 'unable to read input',
110
+ )
111
+ }
99
112
  }
100
- return Buffer.concat(buffers)
101
113
  }
102
114
  throw new Error('Invalid input')
103
115
  }
@@ -111,10 +123,16 @@ describe('Bodies', () => {
111
123
  })
112
124
  server.method(
113
125
  'io.example.validationTest',
114
- (ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => ({
115
- encoding: 'json',
116
- body: ctx.input?.body,
117
- }),
126
+ (ctx: { params: xrpcServer.Params; input?: xrpcServer.HandlerInput }) => {
127
+ if (ctx.input?.body instanceof Readable) {
128
+ throw new Error('Input is readable')
129
+ }
130
+
131
+ return {
132
+ encoding: 'json',
133
+ body: ctx.input?.body ?? null,
134
+ }
135
+ },
118
136
  )
119
137
  server.method('io.example.validationTestTwo', () => ({
120
138
  encoding: 'json',
@@ -338,24 +356,106 @@ describe('Bodies', () => {
338
356
  expect(streamResponse.data.cid).toEqual(expectedCid.toString())
339
357
  })
340
358
 
341
- it('supports blobs and compression', async () => {
359
+ it('supports blob uploads', async () => {
360
+ const bytes = randomBytes(1024)
361
+ const expectedCid = await cidForCbor(bytes)
362
+
363
+ const { data } = await client.call('io.example.blobTest', {}, bytes, {
364
+ encoding: 'application/octet-stream',
365
+ })
366
+ expect(data.cid).toEqual(expectedCid.toString())
367
+ })
368
+
369
+ it(`supports identity encoding`, async () => {
370
+ const bytes = randomBytes(1024)
371
+ const expectedCid = await cidForCbor(bytes)
372
+
373
+ const { data } = await client.call('io.example.blobTest', {}, bytes, {
374
+ encoding: 'application/octet-stream',
375
+ headers: { 'content-encoding': 'identity' },
376
+ })
377
+ expect(data.cid).toEqual(expectedCid.toString())
378
+ })
379
+
380
+ it('supports gzip encoding', async () => {
342
381
  const bytes = randomBytes(1024)
343
382
  const expectedCid = await cidForCbor(bytes)
344
383
 
345
- const { data: uncompressed } = await client.call(
384
+ const { data } = await client.call(
346
385
  'io.example.blobTest',
347
386
  {},
348
- bytes,
387
+ gzipSync(bytes),
349
388
  {
350
389
  encoding: 'application/octet-stream',
390
+ headers: {
391
+ 'content-encoding': 'gzip',
392
+ },
351
393
  },
352
394
  )
353
- expect(uncompressed.cid).toEqual(expectedCid.toString())
395
+ expect(data.cid).toEqual(expectedCid.toString())
396
+ })
354
397
 
355
- const { data: compressed } = await client.call(
398
+ it('supports deflate encoding', async () => {
399
+ const bytes = randomBytes(1024)
400
+ const expectedCid = await cidForCbor(bytes)
401
+
402
+ const { data } = await client.call(
356
403
  'io.example.blobTest',
357
404
  {},
358
- gzipSync(bytes),
405
+ deflateSync(bytes),
406
+ {
407
+ encoding: 'application/octet-stream',
408
+ headers: {
409
+ 'content-encoding': 'deflate',
410
+ },
411
+ },
412
+ )
413
+ expect(data.cid).toEqual(expectedCid.toString())
414
+ })
415
+
416
+ it('supports br encoding', async () => {
417
+ const bytes = randomBytes(1024)
418
+ const expectedCid = await cidForCbor(bytes)
419
+
420
+ const { data } = await client.call(
421
+ 'io.example.blobTest',
422
+ {},
423
+ brotliCompressSync(bytes),
424
+ {
425
+ encoding: 'application/octet-stream',
426
+ headers: {
427
+ 'content-encoding': 'br',
428
+ },
429
+ },
430
+ )
431
+ expect(data.cid).toEqual(expectedCid.toString())
432
+ })
433
+
434
+ it('supports multiple encodings', async () => {
435
+ const bytes = randomBytes(1024)
436
+ const expectedCid = await cidForCbor(bytes)
437
+
438
+ const { data } = await client.call(
439
+ 'io.example.blobTest',
440
+ {},
441
+ brotliCompressSync(deflateSync(gzipSync(bytes))),
442
+ {
443
+ encoding: 'application/octet-stream',
444
+ headers: {
445
+ 'content-encoding': 'gzip, identity, deflate, identity, br, identity',
446
+ },
447
+ },
448
+ )
449
+ expect(data.cid).toEqual(expectedCid.toString())
450
+ })
451
+
452
+ it('fails gracefully on invalid encodings', async () => {
453
+ const bytes = randomBytes(1024)
454
+
455
+ const promise = client.call(
456
+ 'io.example.blobTest',
457
+ {},
458
+ brotliCompressSync(bytes),
359
459
  {
360
460
  encoding: 'application/octet-stream',
361
461
  headers: {
@@ -363,7 +463,8 @@ describe('Bodies', () => {
363
463
  },
364
464
  },
365
465
  )
366
- expect(compressed.cid).toEqual(expectedCid.toString())
466
+
467
+ await expect(promise).rejects.toThrow('unable to read input')
367
468
  })
368
469
 
369
470
  it('supports empty payload', async () => {
@@ -428,9 +529,7 @@ describe('Bodies', () => {
428
529
  })
429
530
  })
430
531
 
431
- // @TODO: figure out why this is failing dependent on the prev test being run
432
- // https://github.com/bluesky-social/atproto/pull/550/files#r1106400413
433
- it.skip('errors on an empty Content-type on blob upload', async () => {
532
+ it('errors on an empty Content-type on blob upload', async () => {
434
533
  // empty mimetype, but correct syntax
435
534
  const res = await fetch(`${url}/xrpc/io.example.blobTest`, {
436
535
  method: 'post',
@@ -0,0 +1,89 @@
1
+ import { parseUrlNsid } from '../src/util'
2
+
3
+ const testValid = (url: string, expected: string) => {
4
+ expect(parseUrlNsid(url)).toBe(expected)
5
+ }
6
+
7
+ const testInvalid = (url: string, errorMessage = 'invalid xrpc path') => {
8
+ expect(() => parseUrlNsid(url)).toThrow(errorMessage)
9
+ }
10
+
11
+ describe('parseUrlNsid', () => {
12
+ it('should extract the NSID from the URL', () => {
13
+ testValid('/xrpc/blee.blah.bloo', 'blee.blah.bloo')
14
+ testValid('/xrpc/blee.blah.bloo?foo[]', 'blee.blah.bloo')
15
+ testValid('/xrpc/blee.blah.bloo?foo=bar', 'blee.blah.bloo')
16
+ testValid('/xrpc/com.example.nsid', 'com.example.nsid')
17
+ testValid('/xrpc/com.example.nsid?foo=bar', 'com.example.nsid')
18
+ testValid('/xrpc/com.example-domain.nsid', 'com.example-domain.nsid')
19
+ })
20
+
21
+ it('should allow a trailing slash', () => {
22
+ testValid('/xrpc/blee.blah.bloo/?', 'blee.blah.bloo')
23
+ testValid('/xrpc/blee.blah.bloo/?foo=', 'blee.blah.bloo')
24
+ testValid('/xrpc/blee.blah.bloo/?bool', 'blee.blah.bloo')
25
+ testValid('/xrpc/com.example.nsid/', 'com.example.nsid')
26
+ })
27
+
28
+ it('should throw an error if the URL is too short', () => {
29
+ testInvalid('/xrpc/a')
30
+ })
31
+
32
+ it('should throw an error if the URL is empty', () => {
33
+ testInvalid('')
34
+ })
35
+
36
+ it('should throw an error if the URL is missing the NSID', () => {
37
+ testInvalid('/xrpc/')
38
+ testInvalid('/xrpc/?')
39
+ testInvalid('/xrpc/?foo=bar')
40
+ })
41
+
42
+ it('should throw an error if the URL contains extra path segments', () => {
43
+ testInvalid('/xrpc/123/extra')
44
+ testInvalid('/xrpc/123/extra?foo=bar')
45
+ })
46
+
47
+ it('should throw an error if the URL is missing the XRPC path prefix', () => {
48
+ testInvalid('/foo/123')
49
+ testInvalid('/foo/com.example.nsid')
50
+ })
51
+
52
+ it('should throw an error if the NSID starts with a dot', () => {
53
+ testInvalid('/xrpc/.')
54
+ testInvalid('/xrpc/..')
55
+ testInvalid('/xrpc/....')
56
+ testInvalid('/xrpc/.com.example.nsid')
57
+ testInvalid('/xrpc/com..example.nsid')
58
+ testInvalid('/xrpc/com.example..nsid')
59
+ testInvalid('/xrpc/com.example.nsid.')
60
+ testInvalid('/xrpc/com.example.nsid./')
61
+ testInvalid('/xrpc/com.example.nsid.?foo=bar')
62
+ testInvalid('/xrpc/com.example.nsid./?foo=bar')
63
+ })
64
+
65
+ it('should throw an error if the NSID contains a misplaced dash', () => {
66
+ testInvalid('/xrpc/-')
67
+ testInvalid('/xrpc/com.example.-nsid')
68
+ testInvalid('/xrpc/com.example-.nsid')
69
+ testInvalid('/xrpc/com.-example.nsid')
70
+ testInvalid('/xrpc/com.-example-.nsid')
71
+ testInvalid('/xrpc/com.example.nsid-')
72
+ testInvalid('/xrpc/-com.example.nsid')
73
+ testInvalid('/xrpc/com.example--domain.nsid')
74
+ })
75
+
76
+ it('should throw an error if the URL starts with a space', () => {
77
+ testInvalid(' /xrpc/com.example.nsid')
78
+ })
79
+
80
+ it('should throw an error if the NSID contains invalid characters', () => {
81
+ testInvalid('/xrpc/com.example.nsid#')
82
+ testInvalid('/xrpc/com.example.nsid!')
83
+ testInvalid('/xrpc/com.example#?nsid')
84
+ testInvalid('/xrpc/!com.example.nsid')
85
+ testInvalid('/xrpc/com.example.nsid ')
86
+ testInvalid('/xrpc/ com.example.nsid')
87
+ testInvalid('/xrpc/com. example.nsid')
88
+ })
89
+ })