diff --git a/packages/verified-fetch/src/utils/responses.ts b/packages/verified-fetch/src/utils/responses.ts index dda0230d..1c2d85e2 100644 --- a/packages/verified-fetch/src/utils/responses.ts +++ b/packages/verified-fetch/src/utils/responses.ts @@ -85,6 +85,19 @@ export function notAcceptableResponse (url: string, body?: SupportedBodyTypes, i return response } +export function notFoundResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response { + const response = new Response(body, { + ...(init ?? {}), + status: 404, + statusText: 'Not Found' + }) + + setType(response, 'basic') + setUrl(response, url) + + return response +} + /** * if body is an Error, it will be converted to a string containing the error message. */ diff --git a/packages/verified-fetch/src/utils/walk-path.ts b/packages/verified-fetch/src/utils/walk-path.ts index 45f2066e..1d14e3ea 100644 --- a/packages/verified-fetch/src/utils/walk-path.ts +++ b/packages/verified-fetch/src/utils/walk-path.ts @@ -1,4 +1,5 @@ -import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type UnixFSEntry } from 'ipfs-unixfs-exporter' +import { CodeError } from '@libp2p/interface' +import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type ObjectNode, type UnixFSEntry } from 'ipfs-unixfs-exporter' import type { CID } from 'multiformats/cid' export interface PathWalkerOptions extends ExporterOptions { @@ -24,7 +25,7 @@ export async function walkPath (blockstore: ReadableStorage, path: string, optio } if (terminalElement == null) { - throw new Error('No terminal element found') + throw new CodeError('No terminal element found', 'NO_TERMINAL_ELEMENT') } return { @@ -32,3 +33,7 @@ export async function walkPath (blockstore: ReadableStorage, path: string, optio terminalElement } } + +export function objectNodeGuard (node: UnixFSEntry): node is ObjectNode { + return node.type === 'object' +} diff --git a/packages/verified-fetch/src/verified-fetch.ts b/packages/verified-fetch/src/verified-fetch.ts index 56ebb188..5e6a75e2 100644 --- a/packages/verified-fetch/src/verified-fetch.ts +++ b/packages/verified-fetch/src/verified-fetch.ts @@ -25,15 +25,15 @@ import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterab import { tarStream } from './utils/get-tar-stream.js' import { parseResource } from './utils/parse-resource.js' import { setCacheControlHeader } from './utils/response-headers.js' -import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse } from './utils/responses.js' +import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse, notFoundResponse } from './utils/responses.js' import { selectOutputType } from './utils/select-output-type.js' -import { walkPath } from './utils/walk-path.js' +import { objectNodeGuard, 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 { ParsedUrlStringResults } from './utils/parse-url-string' import type { Helia } from '@helia/interface' import type { DNSResolver } from '@multiformats/dns/resolvers' -import type { UnixFSEntry } from 'ipfs-unixfs-exporter' +import type { ObjectNode, UnixFSEntry } from 'ipfs-unixfs-exporter' import type { CID } from 'multiformats/cid' interface VerifiedFetchComponents { @@ -236,8 +236,33 @@ export class VerifiedFetch { private async handleDagCbor ({ resource, cid, path, accept, options }: FetchHandlerFunctionArg): Promise { this.log.trace('fetching %c/%s', cid, path) + let terminalElement: ObjectNode | undefined + let ipfsRoots: CID[] | undefined + + // need to walk path, if it exists, to get the terminal element + try { + const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options) + ipfsRoots = pathDetails.ipfsRoots + const potentialTerminalElement = pathDetails.terminalElement + if (potentialTerminalElement == null) { + return notFoundResponse(resource.toString()) + } + if (objectNodeGuard(potentialTerminalElement)) { + terminalElement = potentialTerminalElement + } + } catch (err: any) { + if (options?.signal?.aborted === true) { + throw new AbortError('signal aborted by user') + } + if (['ERR_NO_PROP', 'NO_TERMINAL_ELEMENT'].includes(err.code)) { + return notFoundResponse(resource.toString()) + } + + this.log.error('error walking path %s', path, err) + return badGatewayResponse(resource.toString(), 'Error walking path') + } + const block = terminalElement?.node ?? await this.helia.blockstore.get(cid, options) - const block = await this.helia.blockstore.get(cid, options) let body: string | Uint8Array if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') { @@ -277,9 +302,14 @@ export class VerifiedFetch { response.headers.set('content-type', accept) + if (ipfsRoots != null) { + response.headers.set('X-Ipfs-Roots', ipfsRoots.map(cid => cid.toV1().toString()).join(',')) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-roots-response-header + } + return response } + // eslint-disable-next-line complexity private async handleDagPb ({ cid, path, resource, options }: FetchHandlerFunctionArg): Promise { let terminalElement: UnixFSEntry | undefined let ipfsRoots: CID[] | undefined @@ -294,6 +324,9 @@ export class VerifiedFetch { if (options?.signal?.aborted === true) { throw new AbortError('signal aborted by user') } + if (['ERR_NO_PROP', 'NO_TERMINAL_ELEMENT'].includes(err.code)) { + return notFoundResponse(resource.toString()) + } this.log.error('error walking path %s', path, err) return badGatewayResponse(resource.toString(), 'Error walking path') diff --git a/packages/verified-fetch/test/verified-fetch.spec.ts b/packages/verified-fetch/test/verified-fetch.spec.ts index 59bb0270..39d3a03f 100644 --- a/packages/verified-fetch/test/verified-fetch.spec.ts +++ b/packages/verified-fetch/test/verified-fetch.spec.ts @@ -769,4 +769,35 @@ describe('@helia/verifed-fetch', () => { expect(new Uint8Array(data)).to.equalBytes(finalRootFileContent) }) }) + + describe('404 paths', () => { + let helia: Helia + let verifiedFetch: VerifiedFetch + let contentTypeParser: Sinon.SinonStub + + beforeEach(async () => { + contentTypeParser = Sinon.stub() + helia = await createHelia() + verifiedFetch = new VerifiedFetch({ + helia + }, { + contentTypeParser + }) + }) + + afterEach(async () => { + await stop(helia, verifiedFetch) + }) + + it('returns a 404 when walking dag-cbor for non-existent path', async () => { + const obj = { + hello: 'world' + } + const c = dagCbor(helia) + const cid = await c.add(obj) + + const resp = await verifiedFetch.fetch(`http://example.com/ipfs/${cid}/foo/i-do-not-exist`) + expect(resp.status).to.equal(404) + }) + }) })