Skip to content

Commit e92086a

Browse files
authored
feat: support requesting raw IPNS records in @helia/verified-fetch (#443)
Adds support for the application/vnd.ipfs.ipns-record Accept header to return raw IPNS records.
1 parent 70ddd00 commit e92086a

6 files changed

+153
-62
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ console.info(obj) // ...
351351

352352
The `Accept` header can be passed to override certain response processing, or to ensure that the final `Content-Type` of the response is the one that is expected.
353353

354-
If the final `Content-Type` does not match the `Accept` header, or if the content cannot be represented in the format dictated by the `Accept` header, or you have configured a custom content type parser, and that parser returns a value that isn't in the accept header, a [406: Not Acceptible](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406) response will be returned:
354+
If the final `Content-Type` does not match the `Accept` header, or if the content cannot be represented in the format dictated by the `Accept` header, or you have configured a custom content type parser, and that parser returns a value that isn't in the accept header, a [406: Not Acceptable](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406) response will be returned:
355355

356356
```typescript
357357
import { verifiedFetch } from '@helia/verified-fetch'

package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,20 @@
152152
"@ipld/dag-json": "^10.2.0",
153153
"@ipld/dag-pb": "^4.1.0",
154154
"@libp2p/interface": "^1.1.2",
155+
"@libp2p/kad-dht": "^12.0.7",
155156
"@libp2p/peer-id": "^4.0.5",
156157
"cborg": "^4.0.9",
157158
"hashlru": "^2.3.0",
158159
"interface-blockstore": "^5.2.10",
160+
"interface-datastore": "^8.2.11",
159161
"ipfs-unixfs-exporter": "^13.5.0",
160162
"it-map": "^3.0.5",
161163
"it-pipe": "^3.0.1",
162164
"it-tar": "^6.0.4",
163165
"it-to-browser-readablestream": "^2.0.6",
164166
"multiformats": "^13.1.0",
165-
"progress-events": "^1.0.0"
167+
"progress-events": "^1.0.0",
168+
"uint8arrays": "^5.0.2"
166169
},
167170
"devDependencies": {
168171
"@helia/car": "^3.0.0",
@@ -188,8 +191,7 @@
188191
"magic-bytes.js": "^1.8.0",
189192
"p-defer": "^4.0.0",
190193
"sinon": "^17.0.1",
191-
"sinon-ts": "^2.0.0",
192-
"uint8arrays": "^5.0.1"
194+
"sinon-ts": "^2.0.0"
193195
},
194196
"sideEffects": false
195197
}

src/utils/responses.ts

+7
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ export function notAcceptableResponse (body?: BodyInit | null): Response {
2020
statusText: 'Not Acceptable'
2121
})
2222
}
23+
24+
export function badRequestResponse (body?: BodyInit | null): Response {
25+
return new Response(body, {
26+
status: 400,
27+
statusText: 'Bad Request'
28+
})
29+
}

src/verified-fetch.ts

+50-9
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,30 @@ import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs, type UnixFSStats } f
55
import * as ipldDagCbor from '@ipld/dag-cbor'
66
import * as ipldDagJson from '@ipld/dag-json'
77
import { code as dagPbCode } from '@ipld/dag-pb'
8+
import { Record as DHTRecord } from '@libp2p/kad-dht'
9+
import { peerIdFromString } from '@libp2p/peer-id'
10+
import { Key } from 'interface-datastore'
811
import toBrowserReadableStream from 'it-to-browser-readablestream'
912
import { code as jsonCode } from 'multiformats/codecs/json'
1013
import { code as rawCode } from 'multiformats/codecs/raw'
1114
import { identity } from 'multiformats/hashes/identity'
1215
import { CustomProgressEvent } from 'progress-events'
16+
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
17+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
18+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
1319
import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
1420
import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
1521
import { getETag } from './utils/get-e-tag.js'
1622
import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
1723
import { tarStream } from './utils/get-tar-stream.js'
1824
import { parseResource } from './utils/parse-resource.js'
19-
import { notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
25+
import { badRequestResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
2026
import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
2127
import { walkPath } from './utils/walk-path.js'
2228
import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
2329
import type { RequestFormatShorthand } from './types.js'
2430
import type { Helia } from '@helia/interface'
25-
import type { AbortOptions, Logger } from '@libp2p/interface'
31+
import type { AbortOptions, Logger, PeerId } from '@libp2p/interface'
2632
import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
2733
import type { CID } from 'multiformats/cid'
2834

@@ -49,6 +55,11 @@ interface FetchHandlerFunctionArg {
4955
* content cannot be represented in this format a 406 should be returned
5056
*/
5157
accept?: string
58+
59+
/**
60+
* The originally requested resource
61+
*/
62+
resource: string
5263
}
5364

5465
interface FetchHandlerFunction {
@@ -129,8 +140,36 @@ export class VerifiedFetch {
129140
* Accepts an `ipns://...` URL as a string and returns a `Response` containing
130141
* a raw IPNS record.
131142
*/
132-
private async handleIPNSRecord (resource: string, opts?: VerifiedFetchOptions): Promise<Response> {
133-
return notSupportedResponse('vnd.ipfs.ipns-record support is not implemented')
143+
private async handleIPNSRecord ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
144+
if (path !== '' || !resource.startsWith('ipns://')) {
145+
return badRequestResponse('Invalid IPNS name')
146+
}
147+
148+
let peerId: PeerId
149+
150+
try {
151+
peerId = peerIdFromString(resource.replace('ipns://', ''))
152+
} catch (err) {
153+
this.log.error('could not parse peer id from IPNS url %s', resource)
154+
155+
return badRequestResponse('Invalid IPNS name')
156+
}
157+
158+
// since this call happens after parseResource, we've already resolved the
159+
// IPNS name so a local copy should be in the helia datastore, so we can
160+
// just read it out..
161+
const routingKey = uint8ArrayConcat([
162+
uint8ArrayFromString('/ipns/'),
163+
peerId.toBytes()
164+
])
165+
const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
166+
const buf = await this.helia.datastore.get(datastoreKey, options)
167+
const record = DHTRecord.deserialize(buf)
168+
169+
const response = okResponse(record.value)
170+
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')
171+
172+
return response
134173
}
135174

136175
/**
@@ -384,28 +423,30 @@ export class VerifiedFetch {
384423
let response: Response
385424
let reqFormat: RequestFormatShorthand | undefined
386425

426+
const handlerArgs = { resource: resource.toString(), cid, path, accept, options }
427+
387428
if (accept === 'application/vnd.ipfs.ipns-record') {
388429
// the user requested a raw IPNS record
389430
reqFormat = 'ipns-record'
390-
response = await this.handleIPNSRecord(resource.toString(), options)
431+
response = await this.handleIPNSRecord(handlerArgs)
391432
} else if (accept === 'application/vnd.ipld.car') {
392433
// the user requested a CAR file
393434
reqFormat = 'car'
394435
query.download = true
395436
query.filename = query.filename ?? `${cid.toString()}.car`
396-
response = await this.handleCar({ cid, path, options })
437+
response = await this.handleCar(handlerArgs)
397438
} else if (accept === 'application/vnd.ipld.raw') {
398439
// the user requested a raw block
399440
reqFormat = 'raw'
400441
query.download = true
401442
query.filename = query.filename ?? `${cid.toString()}.bin`
402-
response = await this.handleRaw({ cid, path, options })
443+
response = await this.handleRaw(handlerArgs)
403444
} else if (accept === 'application/x-tar') {
404445
// the user requested a TAR file
405446
reqFormat = 'tar'
406447
query.download = true
407448
query.filename = query.filename ?? `${cid.toString()}.tar`
408-
response = await this.handleTar({ cid, path, options })
449+
response = await this.handleTar(handlerArgs)
409450
} else {
410451
// derive the handler from the CID type
411452
const codecHandler = this.codecHandlers[cid.code]
@@ -414,7 +455,7 @@ export class VerifiedFetch {
414455
return notSupportedResponse(`Support for codec with code ${cid.code} is not yet implemented. Please open an issue at https://github.com/ipfs/helia/issues/new`)
415456
}
416457

417-
response = await codecHandler.call(this, { cid, path, accept, options })
458+
response = await codecHandler.call(this, handlerArgs)
418459
}
419460

420461
response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))

test/ipns-record.spec.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { dagCbor } from '@helia/dag-cbor'
2+
import { ipns } from '@helia/ipns'
3+
import { stop } from '@libp2p/interface'
4+
import { createEd25519PeerId } from '@libp2p/peer-id-factory'
5+
import { expect } from 'aegir/chai'
6+
import { marshal, unmarshal } from 'ipns'
7+
import { VerifiedFetch } from '../src/verified-fetch.js'
8+
import { createHelia } from './fixtures/create-offline-helia.js'
9+
import type { Helia } from '@helia/interface'
10+
import type { IPNS } from '@helia/ipns'
11+
12+
describe('ipns records', () => {
13+
let helia: Helia
14+
let name: IPNS
15+
let verifiedFetch: VerifiedFetch
16+
17+
beforeEach(async () => {
18+
helia = await createHelia()
19+
name = ipns(helia)
20+
verifiedFetch = new VerifiedFetch({
21+
helia
22+
})
23+
})
24+
25+
afterEach(async () => {
26+
await stop(helia, verifiedFetch)
27+
})
28+
29+
it('should support fetching a raw IPNS record', async () => {
30+
const obj = {
31+
hello: 'world'
32+
}
33+
const c = dagCbor(helia)
34+
const cid = await c.add(obj)
35+
36+
const peerId = await createEd25519PeerId()
37+
const record = await name.publish(peerId, cid)
38+
39+
const resp = await verifiedFetch.fetch(`ipns://${peerId}`, {
40+
headers: {
41+
accept: 'application/vnd.ipfs.ipns-record'
42+
}
43+
})
44+
expect(resp.status).to.equal(200)
45+
expect(resp.headers.get('content-type')).to.equal('application/vnd.ipfs.ipns-record')
46+
47+
const buf = new Uint8Array(await resp.arrayBuffer())
48+
expect(marshal(record)).to.equalBytes(buf)
49+
50+
const output = unmarshal(buf)
51+
expect(output.value).to.deep.equal(`/ipfs/${cid}`)
52+
})
53+
54+
it('should reject a request for non-IPNS url', async () => {
55+
const resp = await verifiedFetch.fetch('ipfs://QmbxpRxwKXxnJQjnPqm1kzDJSJ8YgkLxH23mcZURwPHjGv', {
56+
headers: {
57+
accept: 'application/vnd.ipfs.ipns-record'
58+
}
59+
})
60+
expect(resp.status).to.equal(400)
61+
})
62+
63+
it('should reject a request for a DNSLink url', async () => {
64+
const resp = await verifiedFetch.fetch('ipns://ipfs.io', {
65+
headers: {
66+
accept: 'application/vnd.ipfs.ipns-record'
67+
}
68+
})
69+
expect(resp.status).to.equal(400)
70+
})
71+
72+
it('should reject a request for a url with a path component', async () => {
73+
const obj = {
74+
hello: 'world'
75+
}
76+
const c = dagCbor(helia)
77+
const cid = await c.add(obj)
78+
79+
const peerId = await createEd25519PeerId()
80+
await name.publish(peerId, cid)
81+
82+
const resp = await verifiedFetch.fetch(`ipns://${peerId}/hello`, {
83+
headers: {
84+
accept: 'application/vnd.ipfs.ipns-record'
85+
}
86+
})
87+
expect(resp.status).to.equal(400)
88+
})
89+
})

test/verified-fetch.spec.ts

+1-49
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { dagCbor } from '@helia/dag-cbor'
22
import { dagJson } from '@helia/dag-json'
3-
import { type IPNS } from '@helia/ipns'
43
import { json } from '@helia/json'
5-
import { unixfs, type UnixFS } from '@helia/unixfs'
4+
import { unixfs } from '@helia/unixfs'
65
import * as ipldDagCbor from '@ipld/dag-cbor'
76
import * as ipldDagJson from '@ipld/dag-json'
87
import { stop } from '@libp2p/interface'
@@ -19,7 +18,6 @@ import Sinon from 'sinon'
1918
import { stubInterface } from 'sinon-ts'
2019
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
2120
import { VerifiedFetch } from '../src/verified-fetch.js'
22-
import { cids } from './fixtures/cids.js'
2321
import { createHelia } from './fixtures/create-offline-helia.js'
2422
import type { Helia } from '@helia/interface'
2523

@@ -54,52 +52,6 @@ describe('@helia/verifed-fetch', () => {
5452
expect(helia.start.callCount).to.equal(1)
5553
})
5654

57-
describe('format not implemented', () => {
58-
let verifiedFetch: VerifiedFetch
59-
60-
before(async () => {
61-
verifiedFetch = new VerifiedFetch({
62-
helia: stubInterface<Helia>({
63-
logger: defaultLogger()
64-
}),
65-
ipns: stubInterface<IPNS>({
66-
resolveDns: async (dnsLink: string) => {
67-
expect(dnsLink).to.equal('mydomain.com')
68-
return {
69-
cid: cids.file,
70-
path: ''
71-
}
72-
}
73-
}),
74-
unixfs: stubInterface<UnixFS>()
75-
})
76-
})
77-
78-
after(async () => {
79-
await verifiedFetch.stop()
80-
})
81-
82-
const formatsAndAcceptHeaders = [
83-
['ipns-record', 'application/vnd.ipfs.ipns-record']
84-
]
85-
86-
for (const [format, acceptHeader] of formatsAndAcceptHeaders) {
87-
// eslint-disable-next-line no-loop-func
88-
it(`returns 501 for ${acceptHeader}`, async () => {
89-
const resp = await verifiedFetch.fetch(`ipns://mydomain.com?format=${format}`)
90-
expect(resp).to.be.ok()
91-
expect(resp.status).to.equal(501)
92-
const resp2 = await verifiedFetch.fetch(cids.file, {
93-
headers: {
94-
accept: acceptHeader
95-
}
96-
})
97-
expect(resp2).to.be.ok()
98-
expect(resp2.status).to.equal(501)
99-
})
100-
}
101-
})
102-
10355
describe('implicit format', () => {
10456
let verifiedFetch: VerifiedFetch
10557

0 commit comments

Comments
 (0)