hypercore-fetch 9.3.1 → 9.5.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.
Files changed (4) hide show
  1. package/README.md +17 -0
  2. package/index.js +63 -22
  3. package/package.json +1 -1
  4. package/test.js +115 -7
package/README.md CHANGED
@@ -53,6 +53,10 @@ Thus you can get the previous version of a file by using `hyper://NAME/$/version
53
53
 
54
54
  If the resource is a file, it may contain the `Last-Modified` header if the file has had a `metadata.mtime` flag set upon update.
55
55
 
56
+ If the resource is a directory, it will contain the `Allow` header to
57
+ indicate whether a hyperdrive is writable (`'HEAD,GET'`) or not
58
+ (`'HEAD,GET,PUT,DELETE'`).
59
+
56
60
  ### `fetch('hyper://NAME/example.txt', {method: 'GET'})`
57
61
 
58
62
  This will attempt to load `example.txt` from the archive labeled by `NAME`.
@@ -124,6 +128,9 @@ The `body` can be any of the options supported by the Fetch API such as a `Strin
124
128
 
125
129
  Note that this is only available with the `writable: true` flag.
126
130
 
131
+ An attempt to `PUT` a file to a hyperdrive which is not writable will
132
+ fail with status `403`.
133
+
127
134
  ### `fetch('hyper://NAME/folder/', {method: 'PUT', body: new FormData()})`
128
135
 
129
136
  You can add multiple files to a folder using the `PUT` method with a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) body.
@@ -141,6 +148,11 @@ You can delete a file or directory tree in a Hyperdrive by using the `DELETE` me
141
148
 
142
149
  `NAME` can either be the 52 character [z32 encoded](https://github.com/mafintosh/z32) key for a Hyperdrive or Hypercore , or a domain to parse with the [DNSLink](https://www.dnslink.io/) standard.
143
150
 
151
+ Note that this is only available with the `writable: true` flag.
152
+
153
+ An attempt to `DELETE` a file in a hyperdrive which is not writable
154
+ will fail with status `403`.
155
+
144
156
  ### `fetch('hyper://NAME/$/extensions/')`
145
157
 
146
158
  You can list the current [hypercore extensions](https://github.com/hypercore-protocol/hypercore#ext--feedregisterextensionname-handlers) that are enabled by doing a `GET` on the `/$/extensions/` directory.
@@ -200,3 +212,8 @@ You can get older views of data in an archive by using the special `/$/version`
200
212
  From there, you can use `GET` and `HEAD` requests with allt he same headers and querystring paramters as non-versioned paths to data.
201
213
 
202
214
  Note that you cannot `PUT` or `DELETE` data in a versioned folder.
215
+
216
+
217
+ ## Limitations:
218
+
219
+ - Since we make use of the special directory `$`, you cannot store files in this folder. If this is a major blocker, feel free to open an issue with alternative folder names we should consider.
package/index.js CHANGED
@@ -25,6 +25,16 @@ const MIME_EVENT_STREAM = 'text/event-stream; charset=utf-8'
25
25
  const HEADER_CONTENT_TYPE = 'Content-Type'
26
26
  const HEADER_LAST_MODIFIED = 'Last-Modified'
27
27
 
28
+ const WRITABLE_METHODS = [
29
+ 'PUT',
30
+ 'DELETE'
31
+ ]
32
+
33
+ const BASIC_METHODS = [
34
+ 'HEAD',
35
+ 'GET'
36
+ ]
37
+
28
38
  export const ERROR_KEY_NOT_CREATED = 'Must create key with POST before reading'
29
39
 
30
40
  const INDEX_FILES = [
@@ -79,7 +89,9 @@ export default async function makeHyperFetch ({
79
89
  router.get(`hyper://${SPECIAL_DOMAIN}/`, getKey)
80
90
  router.post(`hyper://${SPECIAL_DOMAIN}/`, createKey)
81
91
 
92
+ router.put(`hyper://*/${SPECIAL_FOLDER}/${VERSION_FOLDER_NAME}/**`, putFilesVersioned)
82
93
  router.put('hyper://*/**', putFiles)
94
+ router.delete(`hyper://*/${SPECIAL_FOLDER}/${VERSION_FOLDER_NAME}/**`, deleteFilesVersioned)
83
95
  router.delete('hyper://*/**', deleteFiles)
84
96
  }
85
97
 
@@ -244,7 +256,7 @@ export default async function makeHyperFetch ({
244
256
 
245
257
  async function listenExtension (request) {
246
258
  const { hostname, pathname: rawPathname } = new URL(request.url)
247
- const pathname = decodeURI(rawPathname)
259
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
248
260
  const name = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
249
261
 
250
262
  const core = await getCore(`hyper://${hostname}/`)
@@ -266,7 +278,7 @@ export default async function makeHyperFetch ({
266
278
 
267
279
  async function broadcastExtension (request) {
268
280
  const { hostname, pathname: rawPathname } = new URL(request.url)
269
- const pathname = decodeURI(rawPathname)
281
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
270
282
 
271
283
  const name = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
272
284
 
@@ -281,7 +293,7 @@ export default async function makeHyperFetch ({
281
293
 
282
294
  async function extensionToPeer (request) {
283
295
  const { hostname, pathname: rawPathname } = new URL(request.url)
284
- const pathname = decodeURI(rawPathname)
296
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
285
297
 
286
298
  const subFolder = pathname.slice(`/${SPECIAL_FOLDER}/${EXTENSIONS_FOLDER_NAME}/`.length)
287
299
  const [name, extensionPeer] = subFolder.split('/')
@@ -345,12 +357,16 @@ export default async function makeHyperFetch ({
345
357
 
346
358
  async function putFiles (request) {
347
359
  const { hostname, pathname: rawPathname } = new URL(request.url)
348
- const pathname = decodeURI(rawPathname)
360
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
349
361
  const contentType = request.headers.get('Content-Type') || ''
350
362
  const isFormData = contentType.includes('multipart/form-data')
351
363
 
352
364
  const drive = await getDrive(`hyper://${hostname}`)
353
365
 
366
+ if (!drive.db.feed.writable) {
367
+ return { status: 403, body: `Cannot PUT file to read-only drive: ${drive.url}`, headers: { Location: request.url } }
368
+ }
369
+
354
370
  if (isFormData) {
355
371
  // It's a form! Get the files out and process them
356
372
  const formData = await request.formData()
@@ -367,25 +383,37 @@ export default async function makeHyperFetch ({
367
383
  )
368
384
  }
369
385
  } else {
370
- await pipelinePromise(
371
- Readable.from(request.body),
372
- drive.createWriteStream(pathname, {
373
- metadata: {
374
- mtime: Date.now()
375
- }
376
- })
377
- )
386
+ if (pathname.endsWith('/')) {
387
+ return { status: 405, body: 'Cannot PUT file with trailing slash', headers: { Location: request.url } }
388
+ } else {
389
+ await pipelinePromise(
390
+ Readable.from(request.body),
391
+ drive.createWriteStream(pathname, {
392
+ metadata: {
393
+ mtime: Date.now()
394
+ }
395
+ })
396
+ )
397
+ }
378
398
  }
379
399
 
380
400
  return { status: 201, headers: { Location: request.url } }
381
401
  }
382
402
 
403
+ function putFilesVersioned (request) {
404
+ return { status: 405, body: 'Cannot PUT file to old version', headers: { Location: request.url } }
405
+ }
406
+
383
407
  async function deleteFiles (request) {
384
408
  const { hostname, pathname: rawPathname } = new URL(request.url)
385
- const pathname = decodeURI(rawPathname)
409
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
386
410
 
387
411
  const drive = await getDrive(`hyper://${hostname}`)
388
412
 
413
+ if (!drive.db.feed.writable) {
414
+ return { status: 403, body: `Cannot DELETE file in read-only drive: ${drive.url}`, headers: { Location: request.url } }
415
+ }
416
+
389
417
  if (pathname.endsWith('/')) {
390
418
  let didDelete = false
391
419
  for await (const entry of drive.list(pathname)) {
@@ -408,10 +436,14 @@ export default async function makeHyperFetch ({
408
436
  return { status: 200 }
409
437
  }
410
438
 
439
+ function deleteFilesVersioned (request) {
440
+ return { status: 405, body: 'Cannot DELETE old version', headers: { Location: request.url } }
441
+ }
442
+
411
443
  async function headFilesVersioned (request) {
412
444
  const url = new URL(request.url)
413
445
  const { hostname, pathname: rawPathname, searchParams } = url
414
- const pathname = decodeURI(rawPathname)
446
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
415
447
 
416
448
  const accept = request.headers.get('Accept') || ''
417
449
  const isRanged = request.headers.get('Range') || ''
@@ -419,7 +451,7 @@ export default async function makeHyperFetch ({
419
451
 
420
452
  const parts = pathname.split('/')
421
453
  const version = parts[3]
422
- const realPath = parts.slice(4).join('/')
454
+ const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
423
455
 
424
456
  const drive = await getDrive(`hyper://${hostname}`)
425
457
 
@@ -431,7 +463,7 @@ export default async function makeHyperFetch ({
431
463
  async function headFiles (request) {
432
464
  const url = new URL(request.url)
433
465
  const { hostname, pathname: rawPathname, searchParams } = url
434
- const pathname = decodeURI(rawPathname)
466
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
435
467
 
436
468
  const accept = request.headers.get('Accept') || ''
437
469
  const isRanged = request.headers.get('Range') || ''
@@ -446,10 +478,15 @@ export default async function makeHyperFetch ({
446
478
  const isDirectory = pathname.endsWith('/')
447
479
  const fullURL = new URL(pathname, drive.url).href
448
480
 
481
+ const isWritable = writable && drive.db.feed.writable
482
+
483
+ const Allow = isWritable ? BASIC_METHODS.concat(WRITABLE_METHODS) : BASIC_METHODS
484
+
449
485
  const resHeaders = {
450
486
  ETag: `${drive.version}`,
451
487
  'Accept-Ranges': 'bytes',
452
- Link: `<${fullURL}>; rel="canonical"`
488
+ Link: `<${fullURL}>; rel="canonical"`,
489
+ Allow
453
490
  }
454
491
 
455
492
  if (isDirectory) {
@@ -507,7 +544,7 @@ export default async function makeHyperFetch ({
507
544
  return { status: 404, body: 'Not Found' }
508
545
  }
509
546
 
510
- resHeaders.ETag = `${entry.seq}`
547
+ resHeaders.ETag = `${entry.seq + 1}`
511
548
  resHeaders['Content-Length'] = `${entry.value.blob.byteLength}`
512
549
 
513
550
  const contentType = getMimeType(path)
@@ -546,7 +583,7 @@ export default async function makeHyperFetch ({
546
583
  async function getFilesVersioned (request) {
547
584
  const url = new URL(request.url)
548
585
  const { hostname, pathname: rawPathname, searchParams } = url
549
- const pathname = decodeURI(rawPathname)
586
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
550
587
 
551
588
  const accept = request.headers.get('Accept') || ''
552
589
  const isRanged = request.headers.get('Range') || ''
@@ -554,7 +591,7 @@ export default async function makeHyperFetch ({
554
591
 
555
592
  const parts = pathname.split('/')
556
593
  const version = parts[3]
557
- const realPath = parts.slice(4).join('/')
594
+ const realPath = ensureLeadingSlash(parts.slice(4).join('/'))
558
595
 
559
596
  const drive = await getDrive(`hyper://${hostname}`)
560
597
 
@@ -567,7 +604,7 @@ export default async function makeHyperFetch ({
567
604
  async function getFiles (request) {
568
605
  const url = new URL(request.url)
569
606
  const { hostname, pathname: rawPathname, searchParams } = url
570
- const pathname = decodeURI(rawPathname)
607
+ const pathname = decodeURI(ensureLeadingSlash(rawPathname))
571
608
 
572
609
  const accept = request.headers.get('Accept') || ''
573
610
  const isRanged = request.headers.get('Range') || ''
@@ -651,7 +688,7 @@ async function serveFile (drive, pathname, isRanged) {
651
688
  const entry = await drive.entry(pathname)
652
689
 
653
690
  const resHeaders = {
654
- ETag: `${entry.seq}`,
691
+ ETag: `${entry.seq + 1}`,
655
692
  [HEADER_CONTENT_TYPE]: contentType,
656
693
  'Accept-Ranges': 'bytes',
657
694
  Link: `<${fullURL}>; rel="canonical"`
@@ -749,3 +786,7 @@ function getMimeType (path) {
749
786
  if (mimeType.startsWith('text/')) mimeType = `${mimeType}; charset=utf-8`
750
787
  return mimeType
751
788
  }
789
+
790
+ function ensureLeadingSlash (path) {
791
+ return path.startsWith('/') ? path : '/' + path
792
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypercore-fetch",
3
- "version": "9.3.1",
3
+ "version": "9.5.0",
4
4
  "description": "Implementation of Fetch that uses the Dat SDK for loading p2p content",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/test.js CHANGED
@@ -128,7 +128,7 @@ test('HEAD request', async (t) => {
128
128
  const headersLink = headResponse.headers.get('Link')
129
129
 
130
130
  // Version at which the file was added
131
- t.equal(headersEtag, '1', 'Headers got expected etag')
131
+ t.equal(headersEtag, '2', 'Headers got expected etag')
132
132
  t.equal(headersContentType, 'text/plain; charset=utf-8', 'Headers got expected mime type')
133
133
  t.ok(headersContentLength, "Headers have 'Content-Length' set.")
134
134
  t.ok(headersLastModified, "Headers have 'Last-Modified' set.")
@@ -467,7 +467,7 @@ test('EventSource extension messages', async (t) => {
467
467
  })
468
468
 
469
469
  test('Resolve DNS', async (t) => {
470
- const loadResponse = await fetch('hyper://example2.mauve.moe/?noResolve')
470
+ const loadResponse = await fetch('hyper://blog.mauve.moe/?noResolve')
471
471
 
472
472
  const entries = await loadResponse.json()
473
473
 
@@ -484,7 +484,7 @@ test('Error on invalid hostname', async (t) => {
484
484
  }
485
485
  })
486
486
 
487
- test('GET older version of file from VERSION folder', async (t) => {
487
+ test('Old versions in VERSION folder', async (t) => {
488
488
  const created = await nextURL(t)
489
489
 
490
490
  const fileName = 'example.txt'
@@ -494,6 +494,7 @@ test('GET older version of file from VERSION folder', async (t) => {
494
494
 
495
495
  const fileURL = new URL(`/${fileName}`, created)
496
496
  const versionFileURL = new URL(`/$/version/2/${fileName}`, created)
497
+ const versionRootURL = new URL('/$/version/1/', created)
497
498
 
498
499
  await checkResponse(
499
500
  await fetch(fileURL, { method: 'PUT', body: data1 }), t
@@ -502,13 +503,120 @@ test('GET older version of file from VERSION folder', async (t) => {
502
503
  await fetch(fileURL, { method: 'PUT', body: data2 }), t
503
504
  )
504
505
 
505
- const versionedResponse = await fetch(versionFileURL)
506
+ const versionedFileResponse = await fetch(versionFileURL)
507
+ await checkResponse(versionedFileResponse, t, 'Able to GET versioned file')
508
+ const versionedFileData = await versionedFileResponse.text()
509
+ t.equal(versionedFileData, data1, 'Old data got loaded')
506
510
 
507
- await checkResponse(versionedResponse, t, 'Able to GET versioned file')
511
+ const versionedRootResponse = await fetch(versionRootURL)
512
+ await checkResponse(versionedRootResponse, t, 'Able to GET versioned root')
513
+ const versionedRootContents = await versionedRootResponse.json()
514
+ t.deepEqual(versionedRootContents, [], 'Old root content got loaded')
508
515
 
509
- const versionedData = await versionedResponse.text()
516
+ // PUT on old version should fail
517
+ const putResponse = await fetch(versionFileURL, {
518
+ method: 'PUT',
519
+ body: SAMPLE_CONTENT
520
+ })
521
+ if (putResponse.ok) {
522
+ throw new Error('PUT old version of file should have failed')
523
+ } else {
524
+ t.equal(putResponse.status, 405, 'PUT old version returned status 405 Not Allowed')
525
+ }
526
+
527
+ // DELETE on old version should fail
528
+ const deleteResponse = await fetch(versionFileURL, {
529
+ method: 'delete'
530
+ })
531
+ if (deleteResponse.ok) {
532
+ throw new Error('DELETE old version of file should have failed')
533
+ } else {
534
+ t.equal(deleteResponse.status, 405, 'DELETE old version returned status 405 Not Allowed')
535
+ }
536
+ })
537
+
538
+ test('Handle empty string pathname', async (t) => {
539
+ const created = await nextURL(t)
540
+ const urlObject = new URL('', created)
541
+ const urlNoTrailingSlash = urlObject.href.slice(0, -1)
542
+ const versionedURLObject = new URL('/$/version/3/', created)
543
+ const versionedURLNoTrailingSlash = versionedURLObject.href.slice(0, -1)
544
+
545
+ // PUT
546
+ const putResponse = await fetch(urlNoTrailingSlash, { method: 'PUT', body: SAMPLE_CONTENT })
547
+ if (putResponse.ok) {
548
+ throw new Error('PUT file at the root directory should have failed')
549
+ } else {
550
+ t.pass('PUT file at root directory threw an error')
551
+ }
552
+
553
+ // PUT FormData
554
+ const formData = new FormData()
555
+ formData.append('file', new Blob([SAMPLE_CONTENT]), 'example.txt')
556
+ formData.append('file', new Blob([SAMPLE_CONTENT]), 'example2.txt')
557
+
558
+ await checkResponse(
559
+ await fetch(urlNoTrailingSlash, {
560
+ method: 'put',
561
+ body: formData
562
+ }), t
563
+ )
564
+
565
+ // DELETE
566
+ await checkResponse(await fetch(urlNoTrailingSlash, { method: 'DELETE' }), t)
567
+
568
+ // HEAD
569
+ const headResponse = await fetch(urlNoTrailingSlash, { method: 'HEAD' })
570
+ await checkResponse(headResponse, t)
571
+ t.deepEqual(headResponse.headers.get('Etag'), '5', 'HEAD request returns correct Etag')
572
+
573
+ // HEAD (versioned)
574
+ const versionedHeadResponse = await fetch(versionedURLNoTrailingSlash, { method: 'HEAD' })
575
+ await checkResponse(versionedHeadResponse, t)
576
+ t.deepEqual(versionedHeadResponse.headers.get('Etag'), '3', 'Versioned HEAD request returns correct Etag')
577
+
578
+ // GET
579
+ const getResponse = await fetch(urlNoTrailingSlash)
580
+ await checkResponse(getResponse, t)
581
+ t.deepEqual(await getResponse.json(), [], 'Returns empty root directory')
582
+
583
+ // GET (versioned)
584
+ const versionedGetResponse = await fetch(versionedURLNoTrailingSlash)
585
+ await checkResponse(versionedGetResponse, t)
586
+ t.deepEqual(await versionedGetResponse.json(), ['example.txt', 'example2.txt'], 'Returns root directory prior to DELETE')
587
+ })
588
+
589
+ test('Return status 403 Forbidden on attempt to modify read-only hyperdrive', async (t) => {
590
+ const readOnlyURL = 'hyper://blog.mauve.moe/new-file.txt'
591
+ const putResponse = await fetch(readOnlyURL, { method: 'PUT', body: SAMPLE_CONTENT })
592
+ if (putResponse.ok) {
593
+ throw new Error('PUT file to read-only drive should have failed')
594
+ } else {
595
+ t.equal(putResponse.status, 403, 'PUT file to read-only drive returned status 403 Forbidden')
596
+ }
597
+
598
+ const deleteResponse = await fetch(readOnlyURL, { method: 'DELETE' })
599
+ if (deleteResponse.ok) {
600
+ throw new Error('DELETE file in read-only drive should have failed')
601
+ } else {
602
+ t.equal(deleteResponse.status, 403, 'DELETE file to read-only drive returned status 403 Forbidden')
603
+ }
604
+ })
605
+
606
+ test('Check hyperdrive writability', async (t) => {
607
+ const created = await nextURL(t)
510
608
 
511
- t.equal(versionedData, data1, 'Old data got loaded')
609
+ const readOnlyRootDirectory = 'hyper://blog.mauve.moe/?noResolve'
610
+ const readOnlyHeadResponse = await fetch(readOnlyRootDirectory, { method: 'HEAD' })
611
+ await checkResponse(readOnlyHeadResponse, t, 'Able to load HEAD')
612
+ const readOnlyHeadersAllow = readOnlyHeadResponse.headers.get('Allow')
613
+ t.equal(readOnlyHeadersAllow, 'HEAD,GET', 'Expected read-only Allows header')
614
+
615
+ const writableRootDirectory = new URL('/', created)
616
+ const writableHeadResponse = await fetch(writableRootDirectory, { method: 'HEAD' })
617
+ await checkResponse(writableHeadResponse, t, 'Able to load HEAD')
618
+ const writableHeadersAllow = writableHeadResponse.headers.get('Allow')
619
+ t.equal(writableHeadersAllow, 'HEAD,GET,PUT,DELETE', 'Expected writable Allows header')
512
620
  })
513
621
 
514
622
  async function checkResponse (response, t, successMessage = 'Response OK') {