@helia/verified-fetch
Version:
A fetch-like API for obtaining verified & trustless IPFS content on the web
180 lines • 8.51 kB
JavaScript
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