@helia/verified-fetch
Version:
A fetch-like API for obtaining verified & trustless IPFS content on the web
295 lines (277 loc) • 13.6 kB
text/typescript
import * as ipldDagCbor from '@ipld/dag-cbor'
import { base32 } from 'multiformats/bases/base32'
import { CID } from 'multiformats/cid'
import { sha256 } from 'multiformats/hashes/sha2'
import { isLink } from 'multiformats/link'
import { getETag } from '../utils/get-e-tag.js'
import { getIpfsRoots } from '../utils/response-headers.js'
import { isObjectNode } from '../utils/walk-path.js'
import { BasePlugin } from './plugin-base.js'
import type { PluginContext, VerifiedFetchPluginFactory } from './types.js'
import type { ObjectNode } from 'ipfs-unixfs-exporter'
function isPrimitive (value: unknown): boolean {
return value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint' ||
typeof value === 'symbol'
}
/**
* Converts a cborObject and it's children into a small hash that can be used in the etag header.
*
* @see https://github.com/ipfs/boxo/blob/dc60fe747c375c631a92fcfd6c7456f44a760d24/gateway/assets/assets.go#L84
* @see https://github.com/ipfs/boxo/blob/dc60fe747c375c631a92fcfd6c7456f44a760d24/gateway/handler_unixfs_dir.go#L233-L235
*/
async function getAssetHash (cborObject: Record<string, any>): Promise<string> {
/**
* Plugin Version represents a "version of gateway implementation" that allows for cache busting if necessary.
*
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
*/
const pluginVersion = '0.0.1'
const entryDetails = Object.entries(cborObject).reduce((acc, [key, value]) => {
if (isPrimitive(value)) {
return `${acc}${key}${value}`
}
return `${acc}${key}${getAssetHash(value)}`
}, '')
const hashBytes = await sha256.encode(new TextEncoder().encode(pluginVersion + entryDetails))
return base32.encode(hashBytes)
}
/**
* Handles `dag-cbor` content where the Accept: `text/html` header is present.
*/
export class DagCborHtmlPreviewPlugin extends BasePlugin {
readonly id = 'dag-cbor-plugin-html-preview'
readonly codes = [ipldDagCbor.code]
canHandle ({ cid, accept, pathDetails }: PluginContext): boolean {
this.log('checking if we can handle %c with accept %s', cid, accept)
if (pathDetails == null) {
return false
}
if (!isObjectNode(pathDetails.terminalElement)) {
return false
}
if (cid.code !== ipldDagCbor.code) {
return false
}
if (accept == null || !accept.includes('text/html')) {
return false
}
return isObjectNode(pathDetails.terminalElement)
}
async handle (context: PluginContext & Required<Pick<PluginContext, 'byteRangeContext' | 'pathDetails'>> & { pathDetails: { terminalElement: ObjectNode } }): Promise<Response> {
const { cid, path, pathDetails: { terminalElement, ipfsRoots } } = context
this.log.trace('generating html preview for %c/%s', cid, path)
const block = terminalElement.node
let obj: Record<string, any>
try {
obj = ipldDagCbor.decode(block)
} catch (err) {
return new Response(`<pre>Failed to decode DAG-CBOR: ${String(err)}</pre>`, {
status: 500,
headers: { 'Content-Type': 'text/html' }
})
}
const html = this.getHtml({ path, obj, cid })
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
'X-Ipfs-Roots': getIpfsRoots(ipfsRoots),
'Cache-Control': 'public, max-age=604800, stale-while-revalidate=2678400',
Etag: getETag({ cid, reqFormat: context.reqFormat, contentPrefix: `DagIndex-${await getAssetHash(obj)}_CID-` })
}
})
}
getHtml ({ path, obj, cid }: { path: string, obj: Record<string, any>, cid: CID }): string {
const style = `
:root {
--sans-serif: "Plex", system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
--monospace: Consolas, monaco, monospace;
--navy: #073a53;
--teal: #6bc4ce;
--turquoise: #47AFB4;
--steel-gray: #3f5667;
--dark-white: #d9dbe2;
--light-white: #edf0f4;
--near-white: #f7f8fa;
--radius: 4px;
}
body {
color: #34373f;
font-family: var(--sans-serif);
line-height: 1.43;
margin: 0;
word-break: break-all;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
main {
border: 1px solid var(--dark-white);
border-radius: var(--radius);
overflow: hidden;
margin: 1em;
font-size: .875em;
}
main section header {
background-color: var(--near-white);
}
main header, main section:not(:last-child) {
border-bottom: 1px solid var(--dark-white);
}
main header {
padding-top: .7em;
padding-bottom: .7em;
background-color: var(--light-white);
}
main header, main .container {
padding-left: 1em;
padding-right: 1em;
}
section {
display: block;
}
.grid.dag {
grid-template-columns: max-content 1fr;
}
.grid {
display: grid;
overflow-x: auto;
}
.grid.dag > div {
background: white;
}
.grid.dag > div:nth-of-type(2n+1) {
padding-left: 1em;
}
.grid.dag > div:nth-child(4n), .grid.dag > div:nth-child(4n+3) {
background-color: var(--near-white);
}
.grid.dag .grid {
padding: 0;
}
/* change coloring of nested grid
.grid.dag .grid.dag > div:nth-child(4n), .grid.dag .grid.dag > div:nth-child(4n+3) {
background-color: white;
}
.grid.dag .grid.dag div:nth-child(4n+1), .grid.dag .grid.dag div:nth-child(4n+2) {
background-color: var(--near-white);
} */
.grid.dag > div:nth-last-child(-n+2) {
border-bottom: 0;
}
.grid .grid {
overflow-x: visible;
}
.grid > div {
padding: .7em;
border-bottom: 1px solid var(--dark-white);
}
.nowrap {
white-space: nowrap;
}
pre, code {
font-family: var(--monospace);
}
strong {
font-weight: bolder;
}
a:active, a:visited {
color: #00b0e9;
}
.ipfs-hash {
color: #7f8491;
font-family: var(--monospace);
}
a {
color: #117eb3;
text-decoration: none;
}
header { margin-bottom: 2em; }
.ipfs-logo { width: 32px; height: 32px; background: url('data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlo89/56ZQ/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjDu1lo89/6mhTP+zrVP/nplD/5+aRK8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHNiIS6Wjz3/ubFY/761W/+vp1D/urRZ/8vDZf/GvmH/nplD/1BNIm8AAAAAAAAAAAAAAAAAAAAAAAAAAJaPPf+knEj/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf+tpk7/nplD/wAAAAAAAAAAAAAAAJaPPf+2rVX/vrVb/761W/++tVv/vrVb/6+nUP+6tFn/y8Nl/8vDZf/Lw2X/y8Nl/8G6Xv+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/761W/+vp1D/urRZ/8vDZf/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf++tVv/vrVb/761W/++tVv/vbRa/5aPPf+emUP/y8Nl/8vDZf/Lw2X/y8Nl/8vDZf+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/5qTQP+inkb/op5G/6KdRv/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/sqlS/56ZQ//LxWb/0Mlp/9DJaf/Kw2X/oJtE/7+3XP/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf+9tFr/mJE+/7GsUv/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+xrFL/nplD/8vDZf+emUP/AAAAAAAAAACWjz3/op5G/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+inkb/nplD/wAAAAAAAAAAAAAAAKKeRv+3slb/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+1sFX/op5G/wAAAAAAAAAAAAAAAAAAAAAAAAAAop5GUKKeRv/Nxmf/0cpq/9HKav/Rymr/0cpq/83GZ/+inkb/op5GSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G16KeRv/LxWb/y8Vm/6KeRv+inkaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G/6KeRtcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/n8AAPgfAADwDwAAwAMAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAwAMAAPAPAAD4HwAA/n8AAA==') no-repeat center/contain; display: inline-block; }
`
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="description" content="Content-addressed dag-cbor document hosted on IPFS.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlo89/56ZQ/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUjDu1lo89/6mhTP+zrVP/nplD/5+aRK8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHNiIS6Wjz3/ubFY/761W/+vp1D/urRZ/8vDZf/GvmH/nplD/1BNIm8AAAAAAAAAAAAAAAAAAAAAAAAAAJaPPf+knEj/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf+tpk7/nplD/wAAAAAAAAAAAAAAAJaPPf+2rVX/vrVb/761W/++tVv/vrVb/6+nUP+6tFn/y8Nl/8vDZf/Lw2X/y8Nl/8G6Xv+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/761W/+vp1D/urRZ/8vDZf/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/vrVb/761W/++tVv/r6dQ/7q0Wf/Lw2X/y8Nl/8vDZf/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf++tVv/vrVb/761W/++tVv/vbRa/5aPPf+emUP/y8Nl/8vDZf/Lw2X/y8Nl/8vDZf+emUP/AAAAAAAAAACWjz3/vrVb/761W/++tVv/vrVb/5qTQP+inkb/op5G/6KdRv/Lw2X/y8Nl/8vDZf/Lw2X/nplD/wAAAAAAAAAAlo89/761W/++tVv/sqlS/56ZQ//LxWb/0Mlp/9DJaf/Kw2X/oJtE/7+3XP/Lw2X/y8Nl/56ZQ/8AAAAAAAAAAJaPPf+9tFr/mJE+/7GsUv/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+xrFL/nplD/8vDZf+emUP/AAAAAAAAAACWjz3/op5G/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+inkb/nplD/wAAAAAAAAAAAAAAAKKeRv+3slb/0cpq/9HKav/Rymr/0cpq/9HKav/Rymr/0cpq/9HKav+1sFX/op5G/wAAAAAAAAAAAAAAAAAAAAAAAAAAop5GUKKeRv/Nxmf/0cpq/9HKav/Rymr/0cpq/83GZ/+inkb/op5GSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G16KeRv/LxWb/y8Vm/6KeRv+inkaPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAop5G/6KeRtcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/n8AAPgfAADwDwAAwAMAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAwAMAAPAPAAD4HwAA/n8AAA==">
<title>${cid.toString()} DAG-CBOR Preview</title>
<style>${style}</style>
</head>
<body>
<main>
<header>
<div><strong>CID: </strong> <code class="nowrap">${cid}</code></div>
<div><strong>Codec: </strong> ${this.valueHTML('dag-cbor (0x71)', null)}</div>
</header>
<section class="container">
<p>You can download this block as:</p>
<ul>
<li><a href="?format=raw&download=true&filename=${cid.toString()}.bin" rel="nofollow" download="${cid.toString()}.bin">Raw Block</a> (no conversion)</li>
<li><a href="?format=dag-json&download=true&filename=${cid.toString()}.json" rel="nofollow" download="${cid.toString()}">Valid DAG-JSON</a> (specs at <a href="https://ipld.io/specs/codecs/dag-json/spec/" target="_blank" rel="noopener noreferrer">IPLD</a> and <a href="https://www.iana.org/assignments/media-types/application/vnd.ipld.dag-json" target="_blank" rel="noopener noreferrer">IANA</a>)</li>
<li><a href="?format=dag-cbor&download=true&filename=${cid.toString()}.dag-cbor" rel="nofollow" download="${cid.toString()}.dag-cbor">Valid DAG-CBOR</a> (specs at <a href="https://ipld.io/specs/codecs/dag-cbor/spec/" target="_blank" rel="noopener noreferrer">IPLD</a> and <a href="https://www.iana.org/assignments/media-types/application/vnd.ipld.dag-cbor" target="_blank" rel="noopener noreferrer">IANA</a>)</li>
</ul>
</section>
<section>
<header><strong>DAG-CBOR Preview</strong></header>
<div class="grid dag">
${this.renderRows(obj, path)}
</div>
</section>
</main>
</body>
</html>`
}
valueHTML (value: any, link: string | null): string {
let valueString: string
const isALinkObject = isLink(value)
if (!isALinkObject && typeof value !== 'string') {
valueString = JSON.stringify(value)
} else {
// it can be a string or a link object.. call .toString() on it
valueString = value.toString()
}
const valueCodeBlock = `<code class="nowrap">${valueString}</code>`
if (isALinkObject && link != null) {
return `<a class="ipfs-hash" href="/${link}">${valueCodeBlock}</a>`
}
return valueCodeBlock
}
private renderValue (key: string, value: any, currentPath: string): string {
let rows = ''
value.forEach((item: any, idx: number) => {
const itemPath = currentPath ? `${currentPath}/${key}/${idx}` : `${key}/${idx}`
rows += `<div>${this.valueHTML(idx, null)}</div>`
if (isPrimitive(item)) {
rows += `<div>${this.valueHTML(item, itemPath)}</div>`
} else {
rows += '<div class="grid dag">'
rows += this.renderRows(item, itemPath)
rows += '</div>'
}
})
return rows
}
renderRows (obj: Record<string, any>, currentPath: string = ''): string {
let rows = ''
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
rows += `<div>${key}</div>`
rows += '<div class="grid dag">'
rows += this.renderValue(key, value, currentPath)
rows += '</div>'
} else {
const valuePath = currentPath ? `${currentPath}/${key}` : key
rows += `<div>${key}</div><div>${this.valueHTML(value, valuePath)}</div>`
}
}
return rows
}
}
export const dagCborHtmlPreviewPluginFactory: VerifiedFetchPluginFactory = (opts) => new DagCborHtmlPreviewPlugin(opts)