UNPKG

@helia/verified-fetch

Version:

A fetch-like API for obtaining verified & trustless IPFS content on the web

223 lines (196 loc) 9.19 kB
import { unixfs } from '@helia/unixfs' import { code as dagPbCode } from '@ipld/dag-pb' import { AbortError } from '@libp2p/interface' import { exporter } from 'ipfs-unixfs-exporter' import { CustomProgressEvent } from 'progress-events' import { getContentType } from '../utils/get-content-type.js' import { getStreamFromAsyncIterable } from '../utils/get-stream-from-async-iterable.js' import { setIpfsRoots } from '../utils/response-headers.js' import { badGatewayResponse, badRangeResponse, movedPermanentlyResponse, notSupportedResponse, okRangeResponse } from '../utils/responses.js' import { BasePlugin } from './plugin-base.js' import type { PluginContext } from './types.js' import type { CIDDetail } from '../index.js' import type { UnixFSEntry } from 'ipfs-unixfs-exporter' /** * Handles UnixFS and dag-pb content. */ export class DagPbPlugin extends BasePlugin { readonly id = 'dag-pb-plugin' readonly codes = [dagPbCode] canHandle ({ cid, accept, pathDetails, byteRangeContext }: PluginContext): boolean { this.log('checking if we can handle %c with accept %s', cid, accept) if (pathDetails == null) { return false } if (byteRangeContext == null) { return false } return cid.code === dagPbCode } /** * @see https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization */ getRedirectUrl (context: PluginContext): string | null { const { resource, path } = context const redirectCheckNeeded = path === '' ? !resource.toString().endsWith('/') : !path.endsWith('/') if (redirectCheckNeeded) { try { const url = new URL(resource.toString()) if (url.pathname.endsWith('/')) { // url already has a trailing slash return null } // make sure we append slash to end of the path url.pathname = `${url.pathname}/` return url.toString() } catch (err: any) { // resource is likely a CID return `${resource.toString()}/` } } return null } async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext' | 'pathDetails'>>): Promise<Response | null> { const { cid, options, withServerTiming = false, pathDetails, query } = context const { handleServerTiming, contentTypeParser, helia, getBlockstore } = this.pluginOptions const log = this.log let resource = context.resource let path = context.path let redirected = false const byteRangeContext = context.byteRangeContext const ipfsRoots = pathDetails.ipfsRoots const terminalElement = pathDetails.terminalElement let resolvedCID = terminalElement.cid const fs = unixfs({ ...helia, blockstore: getBlockstore(context.cid, context.resource, options?.session ?? true, options) }) if (terminalElement?.type === 'directory') { const dirCid = terminalElement.cid const redirectUrl = this.getRedirectUrl(context) if (redirectUrl != null) { log.trace('directory url normalization spec requires redirect...') if (options?.redirect === 'error') { log('could not redirect to %s as redirect option was set to "error"', redirectUrl) throw new TypeError('Failed to fetch') } else if (options?.redirect === 'manual') { log('returning 301 permanent redirect to %s', redirectUrl) return movedPermanentlyResponse(resource, redirectUrl) } log('following redirect to %s', redirectUrl) // fall-through simulates following the redirect? resource = redirectUrl redirected = true } const rootFilePath = 'index.html' try { log.trace('found directory at %c/%s, looking for index.html', cid, path) const entry = await handleServerTiming('exporter-dir', '', async () => exporter(`/ipfs/${dirCid}/${rootFilePath}`, helia.blockstore, { signal: options?.signal, onProgress: options?.onProgress }), withServerTiming) log.trace('found root file at %c/%s with cid %c', dirCid, rootFilePath, entry.cid) path = rootFilePath resolvedCID = entry.cid } catch (err: any) { if (options?.signal?.aborted) { throw new AbortError(options?.signal?.reason) } this.log.error('error loading path %c/%s', dirCid, rootFilePath, err) context.isDirectory = true context.directoryEntries = [] context.modified++ this.log.trace('attempting to get directory entries because index.html was not found') try { for await (const dirItem of fs.ls(dirCid, { signal: options?.signal, onProgress: options?.onProgress })) { context.directoryEntries.push(dirItem) } // dir-index-html plugin or dir-index-json (future idea?) plugin should handle this return null } catch (e) { log.error('error listing directory %c', dirCid, e) return notSupportedResponse('Unable to get directory contents') } } finally { options?.onProgress?.(new CustomProgressEvent<CIDDetail>('verified-fetch:request:end', { cid: dirCid, path: rootFilePath })) } } try { // attempt to get the exact file size, but timeout quickly. const stat = await fs.stat(resolvedCID, { extended: true, signal: AbortSignal.timeout(500) }) byteRangeContext.setFileSize(stat.size) } catch (err: any) { log.error('error getting exact file size for %c/%s - %e', cid, path, err) byteRangeContext.setFileSize(pathDetails.terminalElement.size) log.trace('using terminal element size of %d for %c/%s', pathDetails.terminalElement.size, cid, path) } try { const entry = await handleServerTiming('exporter-file', '', async () => exporter(resolvedCID, helia.blockstore, { signal: options?.signal, onProgress: options?.onProgress }), withServerTiming) let firstChunk: Uint8Array let contentType: string if (byteRangeContext.isValidRangeRequest) { contentType = await this.handleRangeRequest(context, entry) } else { const asyncIter = entry.content({ signal: options?.signal, onProgress: options?.onProgress }) log('got async iterator for %c/%s', cid, path) const streamAndFirstChunk = await handleServerTiming('stream-and-chunk', '', async () => getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, { onProgress: options?.onProgress, signal: options?.signal }), withServerTiming) const stream = streamAndFirstChunk.stream firstChunk = streamAndFirstChunk.firstChunk contentType = await handleServerTiming('get-content-type', '', async () => getContentType({ filename: query.filename, bytes: firstChunk, path, contentTypeParser, log }), withServerTiming) byteRangeContext.setBody(stream) } // if not a valid range request, okRangeRequest will call okResponse const response = okRangeResponse(resource, byteRangeContext.getBody(contentType), { byteRangeContext, log }, { redirected }) response.headers.set('Content-Type', byteRangeContext.getContentType() ?? contentType) setIpfsRoots(response, ipfsRoots) return response } catch (err: any) { if (options?.signal?.aborted) { throw new AbortError(options?.signal?.reason) } log.error('error streaming %c/%s', cid, path, err) if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') { return badRangeResponse(resource) } return badGatewayResponse(resource.toString(), 'Unable to stream content') } } private async handleRangeRequest (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext' | 'pathDetails'>>, entry: UnixFSEntry): Promise<string> { const { path, byteRangeContext, options, withServerTiming = false } = context const { handleServerTiming, contentTypeParser } = this.pluginOptions const log = this.log // get the first chunk in order to determine the content type const asyncIter = entry.content({ signal: options?.signal, onProgress: options?.onProgress, offset: 0, // 8kb in order to determine the content type length: 8192 }) const { firstChunk } = await getStreamFromAsyncIterable(asyncIter, path ?? '', this.pluginOptions.logger, { onProgress: options?.onProgress, signal: options?.signal }) const contentType = await handleServerTiming('get-content-type', '', async () => getContentType({ bytes: firstChunk, path, contentTypeParser, log }), withServerTiming) byteRangeContext?.setBody((range): AsyncGenerator<Uint8Array, void, unknown> => { if (options?.signal?.aborted) { throw new AbortError(options?.signal?.reason ?? 'aborted while streaming') } return entry.content({ signal: options?.signal, onProgress: options?.onProgress, offset: range.start ?? 0, length: byteRangeContext.getLength(range) }) }, contentType) return contentType } }