diff --git a/src/contentProviders/ipfsProvider.ts b/src/contentProviders/ipfsProvider.ts new file mode 100644 index 0000000..80bd8ea --- /dev/null +++ b/src/contentProviders/ipfsProvider.ts @@ -0,0 +1,307 @@ +import BaseProvider from "./baseProvider.js"; +import { + BlockingResponse, + OnBeforeRequestDetailsType, + OnBeforeSendHeadersDetailsType, + OnHeadersReceivedDetailsType, + OnRequestDetailsType, + StreamFilter, +} from "../types.js"; +import { requestProxies } from "../util.js"; +import browser from "@lumeweb/webextension-polyfill"; +import { ipfsPath, ipnsPath, path } from "is-ipfs"; +import { + fetchIpfs, + fetchIpns, + statIpfs, + statIpns, +} from "@lumeweb/kernel-ipfs-client"; +import ejs from "ejs"; +import { + CONTENT_MODE_BUFFERED, + CONTENT_MODE_CHUNKED, + contentModes, +} from "../mimes.js"; + +const INDEX_HTML_FILES = ["index.html", "index.htm", "index.shtml"]; + +const DIRECTORY_TEMPLATE = ejs.compile(` + + + + + <%= path %> + + + + +
+
+
+
+ Index of <%= path %> +
+ + + + + + + + <% links.forEach(function (link) { %> + + + + + + <% }) %> + +
+
 
+
+ .. +
 
<%= link.name %> + <%= link.size %>
+
+
+ +`); + +interface StatFileResponse { + exists: boolean; + contentType: string | null; + error: any; + directory: boolean; + files: StatFileSubfile[]; + timeout: boolean; + size: number; +} + +interface StatFileSubfile { + name: string; + size: number; +} + +export default class IpfsProvider extends BaseProvider { + async shouldHandleRequest( + details: OnBeforeRequestDetailsType + ): Promise { + let dns = await this.resolveDns(details); + if (dns) { + dns = "/" + dns.replace("://", "/"); + dns = dns.replace(/^\+/, "/"); + } + + if (dns && path(dns)) { + this.setData(details, "hash", dns); + return true; + } + + return false; + } + async handleProxy(details: OnRequestDetailsType): Promise { + return requestProxies; + } + async handleReqHeaders( + details: OnBeforeSendHeadersDetailsType + ): Promise { + return { + requestHeaders: [ + { name: "x-status", value: this.getData(details, "status") }, + ], + }; + } + + async handleRequest( + details: OnBeforeRequestDetailsType + ): Promise { + let urlPath = new URL(details.url).pathname; + let hash = this.getData(details, "hash"); + let resp: StatFileResponse | null = null; + let fetchMethod: typeof fetchIpfs | typeof fetchIpns; + let err; + try { + if (ipfsPath(hash)) { + hash = hash.replace("/ipfs/", ""); + resp = await statIpfs(hash.replace("/ipfs/", ""), urlPath); + fetchMethod = fetchIpfs; + } else if (ipnsPath(hash)) { + hash = hash.replace("/ipns/", ""); + resp = await statIpns(hash.replace("/ipns/", ""), urlPath); + fetchMethod = fetchIpns; + } else { + err = "invalid content"; + } + } catch (e: any) { + err = (e as Error).message; + } + + let contentType = resp?.contentType; + let contentLength = resp?.size; + + let status = "200"; + + if (contentType?.includes(";")) { + contentType = contentType?.split(";").shift(); + } + + if (resp) { + if (!resp.exists) { + err = "404"; + } + if (resp.directory) { + contentType = "text/html"; + } + } + + this.setData(details, "contentType", contentType); + + if (err) { + if (err === "NOT_FOUND") { + err = "404"; + } + if (err === "timeout") { + err = "408"; + } + if (err.includes("no link")) { + err = "404"; + } + + this.setData(details, "error", err); + + if (!isNaN(parseInt(err))) { + status = err; + } + } + + this.setData(details, "status", status); + this.setData(details, "contentLength", contentLength); + + let filterPromiseResolve: any; + let filterPromise = new Promise((resolve) => { + filterPromiseResolve = resolve; + }); + let streamPromise = Promise.resolve(); + const filter: StreamFilter = browser.webRequest.filterResponseData( + details.requestId + ); + filter.ondata = () => {}; + filter.onstop = () => { + filterPromiseResolve(); + }; + + const buffer: Uint8Array[] = []; + + const receiveUpdate = (chunk: Uint8Array) => { + if ( + Object.keys(contentModes).includes(contentType as string) && + [CONTENT_MODE_CHUNKED, CONTENT_MODE_BUFFERED].includes( + contentModes[contentType as string] + ) + ) { + buffer.push(chunk); + return; + } + + filterPromise.then(() => { + streamPromise = streamPromise.then(() => { + filter.write(chunk); + }); + }); + }; + + if (err) { + // receiveUpdate(new TextEncoder().encode(serverErrorTemplate())); + filterPromise.then(() => streamPromise).then(() => filter.close()); + return {}; + } + + if (resp?.directory) { + let indexFiles = + resp?.files.filter((item) => INDEX_HTML_FILES.includes(item.name)) || + []; + + if (indexFiles.length > 0) { + urlPath += `/${indexFiles[0].name}`; + } else { + const renderedDirectory = DIRECTORY_TEMPLATE(resp?.files); + contentLength = renderedDirectory.length; + receiveUpdate(new TextEncoder().encode(renderedDirectory)); + filterPromise + .then(() => streamPromise) + .then(() => { + filter.close(); + }); + this.setData(details, "contentLength", contentLength); + return {}; + } + } + // @ts-ignore + fetchMethod?.(hash, urlPath, receiveUpdate) + .then(() => streamPromise) + .then(() => { + if (buffer.length) { + let mode = contentModes[contentType as string]; + if (mode === CONTENT_MODE_BUFFERED) { + filter.write( + Uint8Array.from( + buffer.reduce( + (previousValue: Uint8Array, currentValue: Uint8Array) => { + return Uint8Array.from([...previousValue, ...currentValue]); + } + ) + ) + ); + } else if (mode == CONTENT_MODE_CHUNKED) { + buffer.forEach(filter.write); + } + } + filter.close(); + }) + .catch((e) => { + console.log("page error", urlPath, e.message); + /* if ( + urlPath.endsWith(".html") || + urlPath.endsWith(".htm") || + urlPath.endsWith(".xhtml") || + urlPath.endsWith(".shtml") + ) { + this.setData(details, "contentType", "text/html"); + let template = serverErrorTemplate(); + contentLength = template.length; + receiveUpdate(new TextEncoder().encode(template)); + this.setData(details, "contentLength", contentLength); + }*/ + filterPromise.then(() => streamPromise).then(() => filter.close()); + }); + + return {}; + } + + async handleHeaders( + details: OnHeadersReceivedDetailsType + ): Promise { + let headers = []; + // const contentLength = this.getData(details, "contentLength"); + + headers.push({ + name: "Content-Type", + value: this.getData(details, "contentType"), + }); + + /* if (contentLength) { + headers.push({ + name: "content-length", + value: contentLength, + }); + }*/ + + return { + responseHeaders: headers, + }; + } +} diff --git a/src/main/background.ts b/src/main/background.ts index 86ad60e..fb2a5bd 100644 --- a/src/main/background.ts +++ b/src/main/background.ts @@ -5,6 +5,7 @@ import InternalProvider from "../contentProviders/internalProvider.js"; import SkynetProvider from "../contentProviders/skynetProvider.js"; import ServerProvider from "../contentProviders/serverProvider.js"; import { init } from "libkernel"; +import IpfsProvider from "../contentProviders/ipfsProvider.js"; declare var browser: any; // tsc let queriesNonce = 1; @@ -191,6 +192,7 @@ const engine = new WebEngine(); engine.registerContentProvider(new InternalProvider(engine)); engine.registerContentProvider(new ServerProvider(engine)); engine.registerContentProvider(new SkynetProvider(engine)); +engine.registerContentProvider(new IpfsProvider(engine)); // @ts-ignore let kernelFrame: HTMLIFrameElement = document.createElement("iframe"); diff --git a/src/mimes.ts b/src/mimes.ts new file mode 100644 index 0000000..b277d2c --- /dev/null +++ b/src/mimes.ts @@ -0,0 +1,15 @@ +export const CONTENT_MODE_BUFFERED = "buffered"; +export const CONTENT_MODE_CHUNKED = "chunked"; +//export const CONTENT_MODE_STREAMED = "streamed"; + +export const contentModes: { [mimeType: string]: string } = { + // Images + "image/png": CONTENT_MODE_BUFFERED, + "image/jpeg": CONTENT_MODE_BUFFERED, + "image/x-citrix-jpeg": CONTENT_MODE_BUFFERED, + "image/gif": CONTENT_MODE_BUFFERED, + "image/webp": CONTENT_MODE_BUFFERED, + + //JS + "application/javascript": CONTENT_MODE_CHUNKED, +};