UNPKG

@helia/verified-fetch

Version:

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

180 lines 8.51 kB
import { code as dagPbCode } from '@ipld/dag-pb'; import { isPromise } from '@libp2p/utils'; import { exporter } from 'ipfs-unixfs-exporter'; import first from 'it-first'; import itToBrowserReadableStream from 'it-to-browser-readablestream'; import toBuffer from 'it-to-buffer'; import * as raw from 'multiformats/codecs/raw'; import { MEDIA_TYPE_OCTET_STREAM, MEDIA_TYPE_DAG_PB } from "../utils/content-types.js"; import { getContentDispositionFilename } from "../utils/get-content-disposition-filename.js"; import { badGatewayResponse, movedPermanentlyResponse, partialContentResponse, okResponse } from '../utils/responses.js'; import { BasePlugin } from './plugin-base.js'; /** * @see https://specs.ipfs.tech/http-gateways/path-gateway/#use-in-directory-url-normalization */ function getRedirectUrl(resource, url, terminalElement) { let uri; try { // try the requested resource uri = new URL(resource); } catch { // fall back to the canonical URL uri = url; } // directories must be requested with a trailing slash if (terminalElement?.type === 'directory' && !uri.pathname.endsWith('/')) { // make sure we append slash to end of the path uri.pathname += '/'; return uri.toString(); } } /** * Handles UnixFS content */ export class UnixFSPlugin extends BasePlugin { id = 'unixfs-plugin'; codes = [dagPbCode, raw.code]; canHandle({ terminalElement, accept }) { const supportsCid = this.codes.includes(terminalElement.cid.code); const supportsAccept = accept.length === 0 || accept.some(header => header.contentType.mediaType === MEDIA_TYPE_OCTET_STREAM || header.contentType.mediaType === MEDIA_TYPE_DAG_PB); return supportsCid && supportsAccept; } async handle(context) { let { url, resource, terminalElement, ipfsRoots } = context; let filename = url.searchParams.get('filename') ?? terminalElement.name; let redirected; if (terminalElement.type === 'directory') { const redirectUrl = getRedirectUrl(resource, url, terminalElement); if (redirectUrl != null) { this.log.trace('directory url normalization spec requires redirect'); if (context.options?.redirect === 'error') { this.log('could not redirect to %s as redirect option was set to "error"', redirectUrl); throw new TypeError('Failed to fetch'); } else if (context.options?.redirect === 'manual') { this.log('returning 301 permanent redirect to %s', redirectUrl); return movedPermanentlyResponse(context.resource, redirectUrl); } this.log('following redirect to %s', redirectUrl); // fall-through simulates following the redirect? resource = redirectUrl; redirected = true; } const dirCid = terminalElement.cid; // if not disabled, search the directory for an index.html file if (context.options?.supportDirectoryIndexes !== false) { const rootFilePath = 'index.html'; try { this.log.trace('found directory at %c/%s, looking for index.html', dirCid, url.pathname); const entry = await context.serverTiming.time('exporter-dir', '', exporter(`/ipfs/${dirCid}/${rootFilePath}`, context.blockstore, context.options)); if (entry.type === 'directory' || entry.type === 'object') { return badGatewayResponse(resource, 'Unable to stream content'); } // use `index.html` as the file name to help with content types filename = rootFilePath; this.log.trace('found directory index at %c/%s with cid %c', dirCid, rootFilePath, entry.cid); return await this.streamFile(resource, entry, filename, redirected, context.range, context.options); } catch (err) { if (err.name !== 'NotFoundError') { this.log.error('error loading path %c/%s - %e', dirCid, rootFilePath, err); throw err; } } } // no index file found, return the directory listing const block = await toBuffer(context.blockstore.get(dirCid, context.options)); return okResponse(resource, block, { headers: { 'content-type': MEDIA_TYPE_DAG_PB, 'content-length': `${block.byteLength}`, 'content-disposition': `${url.searchParams.get('download') === 'true' ? 'attachment' : 'inline'}; ${getContentDispositionFilename(`${dirCid}.dir`)}`, 'x-ipfs-roots': ipfsRoots.map(cid => cid.toV1()).join(','), 'accept-ranges': 'bytes' }, redirected }); } else if (terminalElement.type === 'file' || terminalElement.type === 'raw' || terminalElement.type === 'identity') { this.log('streaming file'); return this.streamFile(resource, terminalElement, filename, redirected, context.range, context.options); } else { this.log.error('cannot stream terminal element type %s', terminalElement.type); return badGatewayResponse(resource, 'Unable to stream content'); } } async streamFile(resource, entry, filename, redirected, rangeHeader, options) { let contentType = MEDIA_TYPE_OCTET_STREAM; // only detect content type for non-range requests to avoid loading blocks // we aren't going to stream to the user if (rangeHeader == null) { contentType = await this.detectContentType(entry, filename, options); } if (rangeHeader != null) { return partialContentResponse(resource, (offset, length) => { return entry.content({ ...(options ?? {}), offset, length }); }, rangeHeader, entry.size, { headers: { 'content-type': contentType, 'content-disposition': `inline; ${getContentDispositionFilename(filename)}`, 'x-ipfs-roots': entry.cid.toString(), 'accept-ranges': 'bytes' }, redirected }); } // nb. if streaming the output fails (network error, unresolvable block, // etc), a "TypeError: Failed to fetch" error will occur return okResponse(resource, itToBrowserReadableStream(entry.content(options)), { headers: { 'content-type': contentType, 'content-length': `${entry.size}`, 'content-disposition': `inline; ${getContentDispositionFilename(filename)}`, 'x-ipfs-roots': entry.cid.toString(), 'accept-ranges': 'bytes' }, redirected }); } async detectContentType(entry, filename, options) { let buf; if (entry.type === 'raw' || entry.type === 'identity') { buf = entry.node; } else { // read the first block of the file buf = await first(entry.content(options)); } if (buf == null) { throw new Error('stream ended before first block was read'); } let contentType; if (this.pluginOptions.contentTypeParser != null) { try { const parsed = this.pluginOptions.contentTypeParser(buf, filename); if (isPromise(parsed)) { const result = await parsed; if (result != null) { contentType = result; } } else if (parsed != null) { contentType = parsed; } this.log.trace('contentTypeParser returned %s for file with name %s', contentType, filename); } catch (err) { this.log.error('error parsing content type - %e', err); } } return contentType ?? MEDIA_TYPE_OCTET_STREAM; } } //# sourceMappingURL=plugin-handle-unixfs.js.map