@helia/verified-fetch 2.4.0 → 2.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.
- package/README.md +192 -0
- package/dist/index.min.js +357 -32
- package/dist/src/index.d.ts +198 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +192 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/plugins/errors.d.ts +25 -0
- package/dist/src/plugins/errors.d.ts.map +1 -0
- package/dist/src/plugins/errors.js +33 -0
- package/dist/src/plugins/errors.js.map +1 -0
- package/dist/src/plugins/index.d.ts +8 -0
- package/dist/src/plugins/index.d.ts.map +1 -0
- package/dist/src/plugins/index.js +7 -0
- package/dist/src/plugins/index.js.map +1 -0
- package/dist/src/plugins/plugin-base.d.ts +19 -0
- package/dist/src/plugins/plugin-base.d.ts.map +1 -0
- package/dist/src/plugins/plugin-base.js +26 -0
- package/dist/src/plugins/plugin-base.js.map +1 -0
- package/dist/src/plugins/plugin-handle-car.d.ts +11 -0
- package/dist/src/plugins/plugin-handle-car.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-car.js +28 -0
- package/dist/src/plugins/plugin-handle-car.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.d.ts +11 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.js +73 -0
- package/dist/src/plugins/plugin-handle-dag-cbor.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-pb.d.ts +15 -0
- package/dist/src/plugins/plugin-handle-dag-pb.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-pb.js +152 -0
- package/dist/src/plugins/plugin-handle-dag-pb.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-walk.d.ts +16 -0
- package/dist/src/plugins/plugin-handle-dag-walk.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dag-walk.js +45 -0
- package/dist/src/plugins/plugin-handle-dag-walk.js.map +1 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.d.ts +9 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.js +37 -0
- package/dist/src/plugins/plugin-handle-dir-index-html.js.map +1 -0
- package/dist/src/plugins/plugin-handle-ipns-record.d.ts +12 -0
- package/dist/src/plugins/plugin-handle-ipns-record.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-ipns-record.js +62 -0
- package/dist/src/plugins/plugin-handle-ipns-record.js.map +1 -0
- package/dist/src/plugins/plugin-handle-json.d.ts +11 -0
- package/dist/src/plugins/plugin-handle-json.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-json.js +51 -0
- package/dist/src/plugins/plugin-handle-json.js.map +1 -0
- package/dist/src/plugins/plugin-handle-raw.d.ts +8 -0
- package/dist/src/plugins/plugin-handle-raw.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-raw.js +80 -0
- package/dist/src/plugins/plugin-handle-raw.js.map +1 -0
- package/dist/src/plugins/plugin-handle-tar.d.ts +12 -0
- package/dist/src/plugins/plugin-handle-tar.d.ts.map +1 -0
- package/dist/src/plugins/plugin-handle-tar.js +36 -0
- package/dist/src/plugins/plugin-handle-tar.js.map +1 -0
- package/dist/src/plugins/plugins.d.ts +5 -0
- package/dist/src/plugins/plugins.d.ts.map +1 -0
- package/dist/src/plugins/plugins.js +5 -0
- package/dist/src/plugins/plugins.js.map +1 -0
- package/dist/src/plugins/types.d.ts +68 -0
- package/dist/src/plugins/types.d.ts.map +1 -0
- package/dist/src/plugins/types.js +2 -0
- package/dist/src/plugins/types.js.map +1 -0
- package/dist/src/types.d.ts +0 -27
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -2
- package/dist/src/types.js.map +1 -1
- package/dist/src/utils/dir-index-html.d.ts +16 -0
- package/dist/src/utils/dir-index-html.d.ts.map +1 -0
- package/dist/src/utils/dir-index-html.js +387 -0
- package/dist/src/utils/dir-index-html.js.map +1 -0
- package/dist/src/utils/get-e-tag.d.ts +1 -1
- package/dist/src/utils/get-e-tag.d.ts.map +1 -1
- package/dist/src/utils/get-e-tag.js +18 -3
- package/dist/src/utils/get-e-tag.js.map +1 -1
- package/dist/src/utils/walk-path.d.ts +3 -2
- package/dist/src/utils/walk-path.d.ts.map +1 -1
- package/dist/src/utils/walk-path.js +1 -1
- package/dist/src/utils/walk-path.js.map +1 -1
- package/dist/src/verified-fetch.d.ts +6 -24
- package/dist/src/verified-fetch.d.ts.map +1 -1
- package/dist/src/verified-fetch.js +164 -387
- package/dist/src/verified-fetch.js.map +1 -1
- package/dist/typedoc-urls.json +32 -24
- package/package.json +6 -2
- package/src/index.ts +199 -0
- package/src/plugins/errors.ts +37 -0
- package/src/plugins/index.ts +8 -0
- package/src/plugins/plugin-base.ts +30 -0
- package/src/plugins/plugin-handle-car.ts +32 -0
- package/src/plugins/plugin-handle-dag-cbor.ts +84 -0
- package/src/plugins/plugin-handle-dag-pb.ts +168 -0
- package/src/plugins/plugin-handle-dag-walk.ts +53 -0
- package/src/plugins/plugin-handle-dir-index-html.ts +44 -0
- package/src/plugins/plugin-handle-ipns-record.ts +69 -0
- package/src/plugins/plugin-handle-json.ts +57 -0
- package/src/plugins/plugin-handle-raw.ts +92 -0
- package/src/plugins/plugin-handle-tar.ts +44 -0
- package/src/plugins/plugins.ts +4 -0
- package/src/plugins/types.ts +73 -0
- package/src/types.ts +0 -34
- package/src/utils/dir-index-html.ts +445 -0
- package/src/utils/get-e-tag.ts +20 -3
- package/src/utils/walk-path.ts +3 -3
- package/src/verified-fetch.ts +187 -430
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { unixfs } from '@helia/unixfs'
|
|
2
|
+
import { code as dagPbCode } from '@ipld/dag-pb'
|
|
3
|
+
import { exporter } from 'ipfs-unixfs-exporter'
|
|
4
|
+
import { CustomProgressEvent } from 'progress-events'
|
|
5
|
+
import { ByteRangeContext } from '../utils/byte-range-context.js'
|
|
6
|
+
import { getStreamFromAsyncIterable } from '../utils/get-stream-from-async-iterable.js'
|
|
7
|
+
import { setIpfsRoots } from '../utils/response-headers.js'
|
|
8
|
+
import { badGatewayResponse, badRangeResponse, movedPermanentlyResponse, notSupportedResponse, okRangeResponse } from '../utils/responses.js'
|
|
9
|
+
import { setContentType } from '../utils/set-content-type.js'
|
|
10
|
+
import { BasePlugin } from './plugin-base.js'
|
|
11
|
+
import type { PluginContext } from './types.js'
|
|
12
|
+
import type { CIDDetail } from '../index.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handles UnixFS and dag-pb content.
|
|
16
|
+
*/
|
|
17
|
+
export class DagPbPlugin extends BasePlugin {
|
|
18
|
+
readonly codes = [dagPbCode]
|
|
19
|
+
canHandle ({ cid, accept, pathDetails }: PluginContext): boolean {
|
|
20
|
+
this.log('checking if we can handle %c with accept %s', cid, accept)
|
|
21
|
+
if (pathDetails == null) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return cid.code === dagPbCode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization
|
|
30
|
+
*/
|
|
31
|
+
getRedirectUrl (context: PluginContext): string | null {
|
|
32
|
+
const { resource, path } = context
|
|
33
|
+
const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/')
|
|
34
|
+
if (redirectCheckNeeded) {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(resource.toString())
|
|
37
|
+
// make sure we append slash to end of the path
|
|
38
|
+
url.pathname = `${url.pathname}/`
|
|
39
|
+
return url.toString()
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
// resource is likely a CID
|
|
42
|
+
return `${resource.toString()}/`
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async handle (context: PluginContext): Promise<Response | null> {
|
|
49
|
+
const { cid, options, withServerTiming = false, pathDetails } = context
|
|
50
|
+
const { handleServerTiming, contentTypeParser, helia, getBlockstore } = this.pluginOptions
|
|
51
|
+
const log = this.log
|
|
52
|
+
let resource = context.resource
|
|
53
|
+
let path = context.path
|
|
54
|
+
|
|
55
|
+
let redirected = false
|
|
56
|
+
const byteRangeContext = new ByteRangeContext(this.pluginOptions.logger, options?.headers)
|
|
57
|
+
|
|
58
|
+
if (pathDetails == null) {
|
|
59
|
+
throw new TypeError('Path details are required')
|
|
60
|
+
}
|
|
61
|
+
const ipfsRoots = pathDetails.ipfsRoots
|
|
62
|
+
const terminalElement = pathDetails.terminalElement
|
|
63
|
+
let resolvedCID = terminalElement.cid
|
|
64
|
+
|
|
65
|
+
if (terminalElement?.type === 'directory') {
|
|
66
|
+
const dirCid = terminalElement.cid
|
|
67
|
+
const redirectUrl = this.getRedirectUrl(context)
|
|
68
|
+
|
|
69
|
+
if (redirectUrl != null) {
|
|
70
|
+
log.trace('directory url normalization spec requires redirect...')
|
|
71
|
+
if (options?.redirect === 'error') {
|
|
72
|
+
log('could not redirect to %s as redirect option was set to "error"', redirectUrl)
|
|
73
|
+
throw new TypeError('Failed to fetch')
|
|
74
|
+
} else if (options?.redirect === 'manual') {
|
|
75
|
+
log('returning 301 permanent redirect to %s', redirectUrl)
|
|
76
|
+
return movedPermanentlyResponse(resource, redirectUrl)
|
|
77
|
+
}
|
|
78
|
+
log('following redirect to %s', redirectUrl)
|
|
79
|
+
|
|
80
|
+
// fall-through simulates following the redirect?
|
|
81
|
+
resource = redirectUrl
|
|
82
|
+
redirected = true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rootFilePath = 'index.html'
|
|
86
|
+
try {
|
|
87
|
+
log.trace('found directory at %c/%s, looking for index.html', cid, path)
|
|
88
|
+
|
|
89
|
+
const entry = await handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, helia.blockstore, {
|
|
90
|
+
signal: options?.signal,
|
|
91
|
+
onProgress: options?.onProgress
|
|
92
|
+
}), withServerTiming)
|
|
93
|
+
|
|
94
|
+
log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid)
|
|
95
|
+
path = rootFilePath
|
|
96
|
+
resolvedCID = entry.cid
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
this.log.error('error loading path %c/%s', dirCid, rootFilePath, err)
|
|
99
|
+
options?.signal?.throwIfAborted()
|
|
100
|
+
context.isDirectory = true
|
|
101
|
+
context.directoryEntries = []
|
|
102
|
+
this.log.trace('attempting to get directory entries because index.html was not found')
|
|
103
|
+
const fs = unixfs({ ...helia, blockstore: getBlockstore(context.cid, context.resource, options?.session ?? true, options) })
|
|
104
|
+
try {
|
|
105
|
+
for await (const dirItem of fs.ls(dirCid, { signal: options?.signal, onProgress: options?.onProgress })) {
|
|
106
|
+
context.directoryEntries.push(dirItem)
|
|
107
|
+
}
|
|
108
|
+
// dir-index-html plugin or dir-index-json (future idea?) plugin should handle this
|
|
109
|
+
return null
|
|
110
|
+
} catch (e) {
|
|
111
|
+
log.error('error listing directory %c', dirCid, e)
|
|
112
|
+
return notSupportedResponse('Unable to get directory contents')
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath }))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// we have a validRangeRequest & terminalElement is a file, we know the size and should set it
|
|
120
|
+
if (byteRangeContext.isRangeRequest && byteRangeContext.isValidRangeRequest && terminalElement.type === 'file') {
|
|
121
|
+
byteRangeContext.setFileSize(terminalElement.unixfs.fileSize())
|
|
122
|
+
|
|
123
|
+
log.trace('fileSize for rangeRequest %d', byteRangeContext.getFileSize())
|
|
124
|
+
}
|
|
125
|
+
const offset = byteRangeContext.offset
|
|
126
|
+
const length = byteRangeContext.length
|
|
127
|
+
log.trace('calling exporter for %c/%s with offset=%o & length=%o', resolvedCID, path, offset, length)
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const entry = await handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, helia.blockstore, {
|
|
131
|
+
signal: options?.signal,
|
|
132
|
+
onProgress: options?.onProgress
|
|
133
|
+
}), withServerTiming)
|
|
134
|
+
|
|
135
|
+
const asyncIter = entry.content({
|
|
136
|
+
signal: options?.signal,
|
|
137
|
+
onProgress: options?.onProgress,
|
|
138
|
+
offset,
|
|
139
|
+
length
|
|
140
|
+
})
|
|
141
|
+
log('got async iterator for %c/%s', cid, path)
|
|
142
|
+
|
|
143
|
+
const { stream, firstChunk } = await handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, {
|
|
144
|
+
onProgress: options?.onProgress,
|
|
145
|
+
signal: options?.signal
|
|
146
|
+
}), withServerTiming)
|
|
147
|
+
|
|
148
|
+
byteRangeContext.setBody(stream)
|
|
149
|
+
// if not a valid range request, okRangeRequest will call okResponse
|
|
150
|
+
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log }, {
|
|
151
|
+
redirected
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await handleServerTiming('set-content-type', '', async () => setContentType({ bytes: firstChunk, path, response, contentTypeParser, log }), withServerTiming)
|
|
155
|
+
|
|
156
|
+
setIpfsRoots(response, ipfsRoots)
|
|
157
|
+
|
|
158
|
+
return response
|
|
159
|
+
} catch (err: any) {
|
|
160
|
+
options?.signal?.throwIfAborted()
|
|
161
|
+
log.error('error streaming %c/%s', cid, path, err)
|
|
162
|
+
if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
|
|
163
|
+
return badRangeResponse(resource)
|
|
164
|
+
}
|
|
165
|
+
return badGatewayResponse(resource.toString(), 'Unable to stream content')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { code as dagCborCode } from '@ipld/dag-cbor'
|
|
2
|
+
import { code as dagPbCode } from '@ipld/dag-pb'
|
|
3
|
+
import { handlePathWalking } from '../utils/walk-path.js'
|
|
4
|
+
import { BasePlugin } from './plugin-base.js'
|
|
5
|
+
import type { PluginContext } from './types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* This plugin should almost always run first because it's going to handle path walking if needed, and will only say it can handle
|
|
9
|
+
* the request if path walking is possible (path is not empty, terminalCid is unknown, and the path has not been walked yet).
|
|
10
|
+
*
|
|
11
|
+
* Once this plugin has run, the PluginContext will be updated and then this plugin will return false for canHandle, so it won't run again.
|
|
12
|
+
*/
|
|
13
|
+
export class DagWalkPlugin extends BasePlugin {
|
|
14
|
+
/**
|
|
15
|
+
* Return false if the path has already been walked, otherwise return true if the CID is encoded with a codec that supports pathing.
|
|
16
|
+
*/
|
|
17
|
+
canHandle (context: PluginContext): boolean {
|
|
18
|
+
this.log('checking if we can handle %c with accept %s', context.cid, context.accept)
|
|
19
|
+
const { pathDetails, cid } = context
|
|
20
|
+
if (pathDetails != null) {
|
|
21
|
+
// path has already been walked
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (cid.code === dagPbCode || cid.code === dagCborCode)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async handle (context: PluginContext): Promise<Response | null> {
|
|
29
|
+
const { cid, resource, options, withServerTiming = false } = context
|
|
30
|
+
const { getBlockstore, handleServerTiming } = this.pluginOptions
|
|
31
|
+
const blockstore = getBlockstore(cid, resource, options?.session ?? true, options)
|
|
32
|
+
// TODO: migrate handlePathWalking into this plugin
|
|
33
|
+
const pathDetails = await handleServerTiming('path-walking', '', async () => handlePathWalking({ ...context, blockstore, log: this.log }), withServerTiming)
|
|
34
|
+
|
|
35
|
+
context.modified++
|
|
36
|
+
if (pathDetails instanceof Response) {
|
|
37
|
+
this.log.trace('path walking failed')
|
|
38
|
+
|
|
39
|
+
if (pathDetails.status === 404) {
|
|
40
|
+
// invalid or incorrect path.. we walked the path but nothing is there
|
|
41
|
+
// send the 404 response
|
|
42
|
+
return pathDetails
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// some error walking the path
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
context.pathDetails = pathDetails
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { code as dagPbCode } from '@ipld/dag-pb'
|
|
2
|
+
import { dirIndexHtml } from '../utils/dir-index-html.js'
|
|
3
|
+
import { okResponse } from '../utils/responses.js'
|
|
4
|
+
import { BasePlugin } from './plugin-base.js'
|
|
5
|
+
import type { PluginContext, VerifiedFetchPluginFactory } from './types.js'
|
|
6
|
+
|
|
7
|
+
export class DirIndexHtmlPlugin extends BasePlugin {
|
|
8
|
+
readonly codes = [dagPbCode]
|
|
9
|
+
canHandle (context: PluginContext): boolean {
|
|
10
|
+
const { cid, pathDetails, directoryEntries } = context
|
|
11
|
+
if (pathDetails == null) {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
if (pathDetails.terminalElement?.type !== 'directory') {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (directoryEntries?.length === 0) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return cid.code === dagPbCode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async handle (context: PluginContext): Promise<Response> {
|
|
26
|
+
const { resource, pathDetails, directoryEntries } = context
|
|
27
|
+
|
|
28
|
+
if (pathDetails?.terminalElement == null) {
|
|
29
|
+
throw new Error('Path details are required')
|
|
30
|
+
}
|
|
31
|
+
if (directoryEntries == null || directoryEntries?.length === 0) {
|
|
32
|
+
throw new Error('Directory entries are required')
|
|
33
|
+
}
|
|
34
|
+
const terminalElement = pathDetails.terminalElement
|
|
35
|
+
|
|
36
|
+
const gatewayURL = resource
|
|
37
|
+
const htmlResponse = dirIndexHtml(terminalElement, directoryEntries, { gatewayURL, log: this.log })
|
|
38
|
+
const response = okResponse(resource, htmlResponse)
|
|
39
|
+
response.headers.set('content-type', 'text/html')
|
|
40
|
+
return response
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const dirIndexHtmlPluginFactory: VerifiedFetchPluginFactory = (opts) => new DirIndexHtmlPlugin(opts)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Record as DHTRecord } from '@libp2p/kad-dht'
|
|
2
|
+
import { Key } from 'interface-datastore'
|
|
3
|
+
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
|
|
4
|
+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
|
|
5
|
+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
6
|
+
import { getPeerIdFromString } from '../utils/get-peer-id-from-string.js'
|
|
7
|
+
import { badRequestResponse, okResponse } from '../utils/responses.js'
|
|
8
|
+
import { PluginFatalError } from './errors.js'
|
|
9
|
+
import { BasePlugin } from './plugin-base.js'
|
|
10
|
+
import type { PluginContext } from './types.js'
|
|
11
|
+
import type { PeerId } from '@libp2p/interface'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Accepts an `ipns://...`, `https?://<ipnsname>.ipns.<domain>`, or `https?://<domain>/ipns/...` URL as a string and
|
|
15
|
+
* returns a `Response` containing a raw IPNS record.
|
|
16
|
+
*/
|
|
17
|
+
export class IpnsRecordPlugin extends BasePlugin {
|
|
18
|
+
readonly codes = []
|
|
19
|
+
canHandle ({ cid, accept, query }: PluginContext): boolean {
|
|
20
|
+
this.log('checking if we can handle %c with accept %s', cid, accept)
|
|
21
|
+
|
|
22
|
+
return accept === 'application/vnd.ipfs.ipns-record' || query.format === 'ipns-record'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async handle (context: PluginContext): Promise<Response> {
|
|
26
|
+
const { resource, path, options } = context
|
|
27
|
+
const { helia } = this.pluginOptions
|
|
28
|
+
context.reqFormat = 'ipns-record'
|
|
29
|
+
if (path !== '' || !(resource.startsWith('ipns://') || resource.includes('.ipns.') || resource.includes('/ipns/'))) {
|
|
30
|
+
this.log.error('invalid request for IPNS name "%s" and path "%s"', resource, path)
|
|
31
|
+
throw new PluginFatalError('ERR_INVALID_IPNS_NAME', 'Invalid IPNS name', { response: badRequestResponse(resource, 'Invalid IPNS name') })
|
|
32
|
+
}
|
|
33
|
+
let peerId: PeerId
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
let peerIdString: string
|
|
37
|
+
if (resource.startsWith('ipns://')) {
|
|
38
|
+
peerIdString = resource.replace('ipns://', '')
|
|
39
|
+
} else if (resource.includes('/ipns/')) {
|
|
40
|
+
peerIdString = resource.split('/ipns/')[1].split('/')[0].split('?')[0]
|
|
41
|
+
} else {
|
|
42
|
+
peerIdString = resource.split('.ipns.')[0].split('://')[1]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.log.trace('trying to parse peer id from "%s"', peerIdString)
|
|
46
|
+
peerId = getPeerIdFromString(peerIdString)
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
this.log.error('could not parse peer id from IPNS url %s', resource, err)
|
|
49
|
+
|
|
50
|
+
throw new PluginFatalError('ERR_NO_PEER_ID_FOUND', 'could not parse peer id from url', { response: badRequestResponse(resource, err) })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// since this call happens after parseResource, we've already resolved the
|
|
54
|
+
// IPNS name so a local copy should be in the helia datastore, so we can
|
|
55
|
+
// just read it out..
|
|
56
|
+
const routingKey = uint8ArrayConcat([
|
|
57
|
+
uint8ArrayFromString('/ipns/'),
|
|
58
|
+
peerId.toMultihash().bytes
|
|
59
|
+
])
|
|
60
|
+
const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
|
|
61
|
+
const buf = await helia.datastore.get(datastoreKey, options)
|
|
62
|
+
const record = DHTRecord.deserialize(buf)
|
|
63
|
+
|
|
64
|
+
const response = okResponse(resource, record.value)
|
|
65
|
+
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')
|
|
66
|
+
|
|
67
|
+
return response
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as ipldDagCbor from '@ipld/dag-cbor'
|
|
2
|
+
import * as ipldDagJson from '@ipld/dag-json'
|
|
3
|
+
import { code as jsonCode } from 'multiformats/codecs/json'
|
|
4
|
+
import { notAcceptableResponse, okResponse } from '../utils/responses.js'
|
|
5
|
+
import { BasePlugin } from './plugin-base.js'
|
|
6
|
+
import type { PluginContext } from './types.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handles `dag-json` content, including requests with Accept: `application/vnd.ipld.dag-cbor` and `application/cbor`.
|
|
10
|
+
*/
|
|
11
|
+
export class JsonPlugin extends BasePlugin {
|
|
12
|
+
readonly codes = [ipldDagJson.code, jsonCode]
|
|
13
|
+
canHandle ({ cid, accept }: PluginContext): boolean {
|
|
14
|
+
this.log('checking if we can handle %c with accept %s', cid, accept)
|
|
15
|
+
|
|
16
|
+
if (accept === 'application/vnd.ipld.dag-json' && cid.code !== ipldDagCbor.code) {
|
|
17
|
+
// we can handle application/vnd.ipld.dag-json, but if the CID codec is ipldDagCbor, DagCborPlugin should handle it
|
|
18
|
+
// TODO: remove the need for deny-listing cases in plugins
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return ipldDagJson.code === cid.code || jsonCode === cid.code
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async handle (context: PluginContext): Promise<Response> {
|
|
26
|
+
const { path, resource, cid, accept, options } = context
|
|
27
|
+
const { getBlockstore } = this.pluginOptions
|
|
28
|
+
const session = options?.session ?? true
|
|
29
|
+
|
|
30
|
+
this.log.trace('fetching %c/%s', cid, path)
|
|
31
|
+
|
|
32
|
+
const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
|
|
33
|
+
const blockstore = getBlockstore(terminalCid, resource, session, options)
|
|
34
|
+
const block = await blockstore.get(terminalCid, options)
|
|
35
|
+
let body: string | Uint8Array
|
|
36
|
+
|
|
37
|
+
if (accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
|
|
38
|
+
try {
|
|
39
|
+
// if vnd.ipld.dag-cbor has been specified, convert to the format - note
|
|
40
|
+
// that this supports more data types than regular JSON, the content-type
|
|
41
|
+
// response header is set so the user knows to process it differently
|
|
42
|
+
const obj = ipldDagJson.decode(block)
|
|
43
|
+
body = ipldDagCbor.encode(obj)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
this.log.error('could not transform %c to application/vnd.ipld.dag-cbor', err)
|
|
46
|
+
return notAcceptableResponse(resource)
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
// skip decoding
|
|
50
|
+
body = block
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const response = okResponse(resource, body)
|
|
54
|
+
response.headers.set('content-type', accept ?? 'application/json')
|
|
55
|
+
return response
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { code as rawCode } from 'multiformats/codecs/raw'
|
|
2
|
+
import { identity } from 'multiformats/hashes/identity'
|
|
3
|
+
import { ByteRangeContext } from '../utils/byte-range-context.js'
|
|
4
|
+
import { notFoundResponse, okRangeResponse } from '../utils/responses.js'
|
|
5
|
+
import { setContentType } from '../utils/set-content-type.js'
|
|
6
|
+
import { PluginFatalError } from './errors.js'
|
|
7
|
+
import { BasePlugin } from './plugin-base.js'
|
|
8
|
+
import type { PluginContext } from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* These are Accept header values that will cause content type sniffing to be
|
|
12
|
+
* skipped and set to these values.
|
|
13
|
+
*/
|
|
14
|
+
const RAW_HEADERS = [
|
|
15
|
+
'application/vnd.ipld.dag-json',
|
|
16
|
+
'application/vnd.ipld.raw',
|
|
17
|
+
'application/octet-stream'
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* if the user has specified an `Accept` header, and it's in our list of
|
|
22
|
+
* allowable "raw" format headers, use that instead of detecting the content
|
|
23
|
+
* type. This avoids the user from receiving something different when they
|
|
24
|
+
* signal that they want to `Accept` a specific mime type.
|
|
25
|
+
*/
|
|
26
|
+
function getOverridenRawContentType ({ headers, accept }: { headers?: HeadersInit, accept?: string }): string | undefined {
|
|
27
|
+
// accept has already been resolved by getResolvedAcceptHeader, if we have it, use it.
|
|
28
|
+
const acceptHeader = accept ?? new Headers(headers).get('accept') ?? ''
|
|
29
|
+
|
|
30
|
+
// e.g. "Accept: text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8"
|
|
31
|
+
const acceptHeaders = acceptHeader.split(',')
|
|
32
|
+
.map(s => s.split(';')[0])
|
|
33
|
+
.map(s => s.trim())
|
|
34
|
+
|
|
35
|
+
for (const mimeType of acceptHeaders) {
|
|
36
|
+
if (mimeType === '*/*') {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (RAW_HEADERS.includes(mimeType ?? '')) {
|
|
41
|
+
return mimeType
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class RawPlugin extends BasePlugin {
|
|
47
|
+
codes: number[] = [rawCode, identity.code]
|
|
48
|
+
|
|
49
|
+
canHandle ({ cid, accept, query }: PluginContext): boolean {
|
|
50
|
+
this.log('checking if we can handle %c with accept %s', cid, accept)
|
|
51
|
+
return accept === 'application/vnd.ipld.raw' || query.format === 'raw'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async handle (context: PluginContext): Promise<Response> {
|
|
55
|
+
const { path, resource, cid, accept, query, options } = context
|
|
56
|
+
const { getBlockstore, contentTypeParser } = this.pluginOptions
|
|
57
|
+
const session = options?.session ?? true
|
|
58
|
+
const log = this.log
|
|
59
|
+
|
|
60
|
+
if (accept === 'application/vnd.ipld.raw' || query.format === 'raw') {
|
|
61
|
+
context.reqFormat = 'raw'
|
|
62
|
+
context.query.download = true
|
|
63
|
+
context.query.filename = context.query.filename ?? `${cid.toString()}.bin`
|
|
64
|
+
log.trace('Set content disposition...')
|
|
65
|
+
} else {
|
|
66
|
+
log.trace('Did NOT setting content disposition...')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (path !== '' && cid.code === rawCode) {
|
|
70
|
+
log.trace('404-ing raw codec request for %c/%s', cid, path)
|
|
71
|
+
// throw new PluginError('ERR_RAW_PATHS_NOT_SUPPORTED', 'Raw codec does not support paths')
|
|
72
|
+
// return notFoundResponse(resource, 'Raw codec does not support paths')
|
|
73
|
+
throw new PluginFatalError('ERR_RAW_PATHS_NOT_SUPPORTED', 'Raw codec does not support paths', { response: notFoundResponse(resource, 'Raw codec does not support paths') })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const byteRangeContext = new ByteRangeContext(this.pluginOptions.logger, options?.headers)
|
|
77
|
+
const terminalCid = context.pathDetails?.terminalElement.cid ?? context.cid
|
|
78
|
+
const blockstore = getBlockstore(terminalCid, resource, session, options)
|
|
79
|
+
const result = await blockstore.get(terminalCid, options)
|
|
80
|
+
byteRangeContext.setBody(result)
|
|
81
|
+
const response = okRangeResponse(resource, byteRangeContext.getBody(), { byteRangeContext, log }, {
|
|
82
|
+
redirected: false
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// if the user has specified an `Accept` header that corresponds to a raw
|
|
86
|
+
// type, honour that header, so for example they don't request
|
|
87
|
+
// `application/vnd.ipld.raw` but get `application/octet-stream`
|
|
88
|
+
await setContentType({ bytes: result, path, response, defaultContentType: getOverridenRawContentType({ headers: options?.headers, accept }), contentTypeParser, log })
|
|
89
|
+
|
|
90
|
+
return response
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { code as dagPbCode } from '@ipld/dag-pb'
|
|
2
|
+
import toBrowserReadableStream from 'it-to-browser-readablestream'
|
|
3
|
+
import { code as rawCode } from 'multiformats/codecs/raw'
|
|
4
|
+
import { getETag } from '../utils/get-e-tag.js'
|
|
5
|
+
import { tarStream } from '../utils/get-tar-stream.js'
|
|
6
|
+
import { notAcceptableResponse, okResponse } from '../utils/responses.js'
|
|
7
|
+
import { BasePlugin } from './plugin-base.js'
|
|
8
|
+
import type { PluginContext } from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Accepts a UnixFS `CID` and returns a `.tar` file containing the file or
|
|
12
|
+
* directory structure referenced by the `CID`.
|
|
13
|
+
*/
|
|
14
|
+
export class TarPlugin extends BasePlugin {
|
|
15
|
+
readonly codes = []
|
|
16
|
+
canHandle ({ cid, accept, query }: PluginContext): boolean {
|
|
17
|
+
this.log('checking if we can handle %c with accept %s', cid, accept)
|
|
18
|
+
return accept === 'application/x-tar' || query.format === 'tar'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async handle (context: PluginContext): Promise<Response> {
|
|
22
|
+
const { cid, path, resource, options, pathDetails } = context
|
|
23
|
+
const { getBlockstore } = this.pluginOptions
|
|
24
|
+
|
|
25
|
+
const terminusElement = pathDetails?.terminalElement.cid ?? cid
|
|
26
|
+
if (terminusElement.code !== dagPbCode && terminusElement.code !== rawCode) {
|
|
27
|
+
return notAcceptableResponse('only UnixFS data can be returned in a TAR file')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
context.reqFormat = 'tar'
|
|
31
|
+
context.query.download = true
|
|
32
|
+
context.query.filename = context.query.filename ?? `${terminusElement.toString()}.tar`
|
|
33
|
+
|
|
34
|
+
const blockstore = getBlockstore(terminusElement, resource, options?.session, options)
|
|
35
|
+
const stream = toBrowserReadableStream<Uint8Array>(tarStream(`/ipfs/${cid}/${path}`, blockstore, options))
|
|
36
|
+
|
|
37
|
+
const response = okResponse(resource, stream)
|
|
38
|
+
response.headers.set('content-type', 'application/x-tar')
|
|
39
|
+
|
|
40
|
+
response.headers.set('etag', getETag({ cid: terminusElement, reqFormat: context.reqFormat, weak: true }))
|
|
41
|
+
|
|
42
|
+
return response
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PluginError } from './errors.js'
|
|
2
|
+
import type { VerifiedFetchInit } from '../index.js'
|
|
3
|
+
import type { ContentTypeParser, RequestFormatShorthand } from '../types.js'
|
|
4
|
+
import type { ParsedUrlStringResults } from '../utils/parse-url-string.js'
|
|
5
|
+
import type { PathWalkerResponse } from '../utils/walk-path.js'
|
|
6
|
+
import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface'
|
|
7
|
+
import type { Helia } from 'helia'
|
|
8
|
+
import type { Blockstore } from 'interface-blockstore'
|
|
9
|
+
import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
|
|
10
|
+
import type { CID } from 'multiformats/cid'
|
|
11
|
+
import type { CustomProgressEvent } from 'progress-events'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Contains common components and functions required by plugins to handle a request.
|
|
15
|
+
* - Read-Only: Plugins can read but shouldn’t rewrite them.
|
|
16
|
+
* - Persistent: Relevant even after the request completes (e.g., logging or metrics).
|
|
17
|
+
*/
|
|
18
|
+
export interface PluginOptions {
|
|
19
|
+
logger: ComponentLogger
|
|
20
|
+
getBlockstore(cid: CID, resource: string | CID, useSession?: boolean, options?: AbortOptions): Blockstore
|
|
21
|
+
handleServerTiming<T>(name: string, description: string, fn: () => Promise<T>, withServerTiming: boolean): Promise<T>
|
|
22
|
+
contentTypeParser?: ContentTypeParser
|
|
23
|
+
helia: Helia
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Represents the ephemeral, modifiable state used by the pipeline.
|
|
28
|
+
* - Mutable: Evolves as you walk the plugin chain.
|
|
29
|
+
* - Shared Data: Allows plugins to communicate partial results, discovered data, or interim errors.
|
|
30
|
+
* - Ephemeral: Typically discarded once fetch(...) completes.
|
|
31
|
+
*/
|
|
32
|
+
export interface PluginContext {
|
|
33
|
+
readonly cid: CID
|
|
34
|
+
readonly path: string
|
|
35
|
+
readonly resource: string
|
|
36
|
+
readonly accept?: string
|
|
37
|
+
/**
|
|
38
|
+
* The last time the context is modified, so we know whether a plugin has modified it.
|
|
39
|
+
* A plugin should increment this value if it modifies the context.
|
|
40
|
+
*/
|
|
41
|
+
modified: number
|
|
42
|
+
withServerTiming?: boolean
|
|
43
|
+
onProgress?(evt: CustomProgressEvent<any>): void
|
|
44
|
+
options?: Omit<VerifiedFetchInit, 'signal'> & AbortOptions
|
|
45
|
+
isDirectory?: boolean
|
|
46
|
+
directoryEntries?: UnixFSEntry[]
|
|
47
|
+
errors?: PluginError[]
|
|
48
|
+
reqFormat?: RequestFormatShorthand
|
|
49
|
+
pathDetails?: PathWalkerResponse
|
|
50
|
+
query: ParsedUrlStringResults['query']
|
|
51
|
+
[key: string]: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface VerifiedFetchPlugin {
|
|
55
|
+
readonly codes: number[]
|
|
56
|
+
readonly log: Logger
|
|
57
|
+
canHandle (context: PluginContext): boolean
|
|
58
|
+
handle (context: PluginContext): Promise<Response | null>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VerifiedFetchPluginFactory {
|
|
62
|
+
(options: PluginOptions): VerifiedFetchPlugin
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PluginErrorOptions {
|
|
66
|
+
fatal?: boolean
|
|
67
|
+
details?: Record<string, any>
|
|
68
|
+
response?: Response
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FatalPluginErrorOptions extends PluginErrorOptions {
|
|
72
|
+
response: Response
|
|
73
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,41 +1,7 @@
|
|
|
1
|
-
import { type AbortOptions } from '@libp2p/interface'
|
|
2
|
-
import { type CID } from 'multiformats/cid'
|
|
3
|
-
import type { VerifiedFetchInit } from './index.js'
|
|
4
|
-
|
|
5
1
|
export type RequestFormatShorthand = 'raw' | 'car' | 'tar' | 'ipns-record' | 'dag-json' | 'dag-cbor' | 'json' | 'cbor'
|
|
6
2
|
|
|
7
3
|
export type SupportedBodyTypes = string | ArrayBuffer | Blob | ReadableStream<Uint8Array> | null
|
|
8
4
|
|
|
9
|
-
export interface FetchHandlerFunctionArg {
|
|
10
|
-
cid: CID
|
|
11
|
-
path: string
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Whether to use a session during fetch operations
|
|
15
|
-
*
|
|
16
|
-
* @default true
|
|
17
|
-
*/
|
|
18
|
-
session: boolean
|
|
19
|
-
|
|
20
|
-
options?: Omit<VerifiedFetchInit, 'signal'> & AbortOptions
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* If present, the user has sent an accept header with this value - if the
|
|
24
|
-
* content cannot be represented in this format a 406 should be returned
|
|
25
|
-
*/
|
|
26
|
-
accept?: string
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The originally requested resource
|
|
30
|
-
*/
|
|
31
|
-
resource: string
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Whether to include server-timing headers in the response.
|
|
35
|
-
*/
|
|
36
|
-
withServerTiming: boolean
|
|
37
|
-
}
|
|
38
|
-
|
|
39
5
|
/**
|
|
40
6
|
* A ContentTypeParser attempts to return the mime type of a given file. It
|
|
41
7
|
* receives the first chunk of the file data and the file name, if it is
|