Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support requesting raw IPNS records in @helia/verified-fetch #443

Merged
merged 11 commits into from
Feb 22, 2024
2 changes: 1 addition & 1 deletion packages/verified-fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ console.info(obj) // ...

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.

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:
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:

```typescript
import { verifiedFetch } from '@helia/verified-fetch'
Expand Down
8 changes: 5 additions & 3 deletions packages/verified-fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,20 @@
"@ipld/dag-json": "^10.2.0",
"@ipld/dag-pb": "^4.1.0",
"@libp2p/interface": "^1.1.2",
"@libp2p/kad-dht": "^12.0.7",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only here to be able to deserialize stored IPNS records that are wrapped in a libp2p record.

Longer term we may wish to split the libp2p record code out of the dht code to make the bundle size a bit smaller.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a tracking issue for this?

"@libp2p/peer-id": "^4.0.5",
"cborg": "^4.0.9",
"hashlru": "^2.3.0",
"interface-blockstore": "^5.2.10",
"interface-datastore": "^8.2.11",
"ipfs-unixfs-exporter": "^13.5.0",
"it-map": "^3.0.5",
"it-pipe": "^3.0.1",
"it-tar": "^6.0.4",
"it-to-browser-readablestream": "^2.0.6",
"multiformats": "^13.1.0",
"progress-events": "^1.0.0"
"progress-events": "^1.0.0",
"uint8arrays": "^5.0.2"
},
"devDependencies": {
"@helia/car": "^3.0.0",
Expand All @@ -188,8 +191,7 @@
"magic-bytes.js": "^1.8.0",
"p-defer": "^4.0.0",
"sinon": "^17.0.1",
"sinon-ts": "^2.0.0",
"uint8arrays": "^5.0.1"
"sinon-ts": "^2.0.0"
},
"sideEffects": false
}
7 changes: 7 additions & 0 deletions packages/verified-fetch/src/utils/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,10 @@ export function notAcceptableResponse (body?: BodyInit | null): Response {
statusText: 'Not Acceptable'
})
}

export function badRequestResponse (body?: BodyInit | null): Response {
return new Response(body, {
status: 400,
statusText: 'Bad Request'
})
}
59 changes: 50 additions & 9 deletions packages/verified-fetch/src/verified-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,30 @@ import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs, type UnixFSStats } f
import * as ipldDagCbor from '@ipld/dag-cbor'
import * as ipldDagJson from '@ipld/dag-json'
import { code as dagPbCode } from '@ipld/dag-pb'
import { Record as DHTRecord } from '@libp2p/kad-dht'
import { peerIdFromString } from '@libp2p/peer-id'
import { Key } from 'interface-datastore'
import toBrowserReadableStream from 'it-to-browser-readablestream'
import { code as jsonCode } from 'multiformats/codecs/json'
import { code as rawCode } from 'multiformats/codecs/raw'
import { identity } from 'multiformats/hashes/identity'
import { CustomProgressEvent } from 'progress-events'
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
import { getContentDispositionFilename } from './utils/get-content-disposition-filename.js'
import { getETag } from './utils/get-e-tag.js'
import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
import { tarStream } from './utils/get-tar-stream.js'
import { parseResource } from './utils/parse-resource.js'
import { notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
import { badRequestResponse, notAcceptableResponse, notSupportedResponse, okResponse } from './utils/responses.js'
import { selectOutputType, queryFormatToAcceptHeader } from './utils/select-output-type.js'
import { walkPath } from './utils/walk-path.js'
import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
import type { RequestFormatShorthand } from './types.js'
import type { Helia } from '@helia/interface'
import type { AbortOptions, Logger } from '@libp2p/interface'
import type { AbortOptions, Logger, PeerId } from '@libp2p/interface'
import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
import type { CID } from 'multiformats/cid'

Expand All @@ -49,6 +55,11 @@ interface FetchHandlerFunctionArg {
* content cannot be represented in this format a 406 should be returned
*/
accept?: string

/**
* The originally requested resource
*/
resource: string
}

interface FetchHandlerFunction {
Expand Down Expand Up @@ -129,8 +140,36 @@ export class VerifiedFetch {
* Accepts an `ipns://...` URL as a string and returns a `Response` containing
* a raw IPNS record.
*/
private async handleIPNSRecord (resource: string, opts?: VerifiedFetchOptions): Promise<Response> {
return notSupportedResponse('vnd.ipfs.ipns-record support is not implemented')
private async handleIPNSRecord ({ resource, cid, path, options }: FetchHandlerFunctionArg): Promise<Response> {
if (path !== '' || !resource.startsWith('ipns://')) {
return badRequestResponse('Invalid IPNS name')
}

let peerId: PeerId

try {
peerId = peerIdFromString(resource.replace('ipns://', ''))
} catch (err) {
this.log.error('could not parse peer id from IPNS url %s', resource)

return badRequestResponse('Invalid IPNS name')
}

// since this call happens after parseResource, we've already resolved the
// IPNS name so a local copy should be in the helia datastore, so we can
// just read it out..
const routingKey = uint8ArrayConcat([
uint8ArrayFromString('/ipns/'),
peerId.toBytes()
])
const datastoreKey = new Key('/dht/record/' + uint8ArrayToString(routingKey, 'base32'), false)
const buf = await this.helia.datastore.get(datastoreKey, options)
const record = DHTRecord.deserialize(buf)

const response = okResponse(record.value)
response.headers.set('content-type', 'application/vnd.ipfs.ipns-record')

return response
}

/**
Expand Down Expand Up @@ -384,28 +423,30 @@ export class VerifiedFetch {
let response: Response
let reqFormat: RequestFormatShorthand | undefined

const handlerArgs = { resource: resource.toString(), cid, path, accept, options }

if (accept === 'application/vnd.ipfs.ipns-record') {
// the user requested a raw IPNS record
reqFormat = 'ipns-record'
response = await this.handleIPNSRecord(resource.toString(), options)
response = await this.handleIPNSRecord(handlerArgs)
} else if (accept === 'application/vnd.ipld.car') {
// the user requested a CAR file
reqFormat = 'car'
query.download = true
query.filename = query.filename ?? `${cid.toString()}.car`
response = await this.handleCar({ cid, path, options })
response = await this.handleCar(handlerArgs)
} else if (accept === 'application/vnd.ipld.raw') {
// the user requested a raw block
reqFormat = 'raw'
query.download = true
query.filename = query.filename ?? `${cid.toString()}.bin`
response = await this.handleRaw({ cid, path, options })
response = await this.handleRaw(handlerArgs)
} else if (accept === 'application/x-tar') {
// the user requested a TAR file
reqFormat = 'tar'
query.download = true
query.filename = query.filename ?? `${cid.toString()}.tar`
response = await this.handleTar({ cid, path, options })
response = await this.handleTar(handlerArgs)
} else {
// derive the handler from the CID type
const codecHandler = this.codecHandlers[cid.code]
Expand All @@ -414,7 +455,7 @@ export class VerifiedFetch {
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`)
}

response = await codecHandler.call(this, { cid, path, accept, options })
response = await codecHandler.call(this, handlerArgs)
}

response.headers.set('etag', getETag({ cid, reqFormat, weak: false }))
Expand Down
89 changes: 89 additions & 0 deletions packages/verified-fetch/test/ipns-record.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { dagCbor } from '@helia/dag-cbor'
import { ipns } from '@helia/ipns'
import { stop } from '@libp2p/interface'
import { createEd25519PeerId } from '@libp2p/peer-id-factory'
import { expect } from 'aegir/chai'
import { marshal, unmarshal } from 'ipns'
import { VerifiedFetch } from '../src/verified-fetch.js'
import { createHelia } from './fixtures/create-offline-helia.js'
import type { Helia } from '@helia/interface'
import type { IPNS } from '@helia/ipns'

describe('ipns records', () => {
let helia: Helia
let name: IPNS
let verifiedFetch: VerifiedFetch

beforeEach(async () => {
helia = await createHelia()
name = ipns(helia)
verifiedFetch = new VerifiedFetch({
helia
})
})

afterEach(async () => {
await stop(helia, verifiedFetch)
})

it('should support fetching a raw IPNS record', async () => {
Copy link
Member

@2color 2color Feb 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const obj = {
hello: 'world'
}
const c = dagCbor(helia)
const cid = await c.add(obj)

const peerId = await createEd25519PeerId()
const record = await name.publish(peerId, cid)

const resp = await verifiedFetch.fetch(`ipns://${peerId}`, {
headers: {
accept: 'application/vnd.ipfs.ipns-record'
}
})
expect(resp.status).to.equal(200)
expect(resp.headers.get('content-type')).to.equal('application/vnd.ipfs.ipns-record')

const buf = new Uint8Array(await resp.arrayBuffer())
expect(marshal(record)).to.equalBytes(buf)

const output = unmarshal(buf)
expect(output.value).to.deep.equal(`/ipfs/${cid}`)
})

it('should reject a request for non-IPNS url', async () => {
const resp = await verifiedFetch.fetch('ipfs://QmbxpRxwKXxnJQjnPqm1kzDJSJ8YgkLxH23mcZURwPHjGv', {
headers: {
accept: 'application/vnd.ipfs.ipns-record'
}
})
expect(resp.status).to.equal(400)
})

it('should reject a request for a DNSLink url', async () => {
const resp = await verifiedFetch.fetch('ipns://ipfs.io', {
headers: {
accept: 'application/vnd.ipfs.ipns-record'
}
})
expect(resp.status).to.equal(400)
})

it('should reject a request for a url with a path component', async () => {
const obj = {
hello: 'world'
}
const c = dagCbor(helia)
const cid = await c.add(obj)

const peerId = await createEd25519PeerId()
await name.publish(peerId, cid)

const resp = await verifiedFetch.fetch(`ipns://${peerId}/hello`, {
headers: {
accept: 'application/vnd.ipfs.ipns-record'
}
})
expect(resp.status).to.equal(400)
})
})
50 changes: 1 addition & 49 deletions packages/verified-fetch/test/verified-fetch.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { dagCbor } from '@helia/dag-cbor'
import { dagJson } from '@helia/dag-json'
import { type IPNS } from '@helia/ipns'
import { json } from '@helia/json'
import { unixfs, type UnixFS } from '@helia/unixfs'
import { unixfs } from '@helia/unixfs'
import * as ipldDagCbor from '@ipld/dag-cbor'
import * as ipldDagJson from '@ipld/dag-json'
import { stop } from '@libp2p/interface'
Expand All @@ -19,7 +18,6 @@ import Sinon from 'sinon'
import { stubInterface } from 'sinon-ts'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { VerifiedFetch } from '../src/verified-fetch.js'
import { cids } from './fixtures/cids.js'
import { createHelia } from './fixtures/create-offline-helia.js'
import type { Helia } from '@helia/interface'

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

describe('format not implemented', () => {
let verifiedFetch: VerifiedFetch

before(async () => {
verifiedFetch = new VerifiedFetch({
helia: stubInterface<Helia>({
logger: defaultLogger()
}),
ipns: stubInterface<IPNS>({
resolveDns: async (dnsLink: string) => {
expect(dnsLink).to.equal('mydomain.com')
return {
cid: cids.file,
path: ''
}
}
}),
unixfs: stubInterface<UnixFS>()
})
})

after(async () => {
await verifiedFetch.stop()
})

const formatsAndAcceptHeaders = [
['ipns-record', 'application/vnd.ipfs.ipns-record']
]

for (const [format, acceptHeader] of formatsAndAcceptHeaders) {
// eslint-disable-next-line no-loop-func
it(`returns 501 for ${acceptHeader}`, async () => {
const resp = await verifiedFetch.fetch(`ipns://mydomain.com?format=${format}`)
expect(resp).to.be.ok()
expect(resp.status).to.equal(501)
const resp2 = await verifiedFetch.fetch(cids.file, {
headers: {
accept: acceptHeader
}
})
expect(resp2).to.be.ok()
expect(resp2.status).to.equal(501)
})
}
})

describe('implicit format', () => {
let verifiedFetch: VerifiedFetch

Expand Down
Loading