diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..ebfab2d --- /dev/null +++ b/dist/index.html @@ -0,0 +1,58 @@ + + + + + + + + + + +
+
+ + +
+ +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0f044e4 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "browser-webapp", + "version": "0.1.0", + "type": "module", + "devDependencies": { + "@lumeweb/presetter-kernel-module-preset": "^0.1.0-develop.1", + "presetter": "*" + }, + "readme": "ERROR: No README data found!", + "_id": "browser-webapp@0.1.0", + "scripts": { + "prepare": "presetter bootstrap", + "build": "run build", + "semantic-release": "semantic-release" + }, + "dependencies": { + "@helia/unixfs": "^1.4.2", + "@lumeweb/kernel-dns-client": "^0.1.0-develop.7", + "@lumeweb/kernel-eth-client": "^0.1.0-develop.16", + "@lumeweb/kernel-handshake-client": "^0.1.0-develop.8", + "@lumeweb/kernel-ipfs-client": "^0.1.0-develop.24", + "@lumeweb/kernel-network-registry-client": "^0.1.0-develop.9", + "@lumeweb/kernel-peer-discovery-client": "^0.0.2-develop.16", + "@lumeweb/kernel-swarm-client": "^0.1.0-develop.10", + "@lumeweb/libkernel": "^0.1.0-develop.63", + "@lumeweb/tld-enum": "^0.1.0-develop.1", + "cheerio": "^1.0.0-rc.12", + "file-type": "^18.5.0", + "is-ipfs": "^8.0.1" + } +} diff --git a/src/clients.ts b/src/clients.ts new file mode 100644 index 0000000..aff54f5 --- /dev/null +++ b/src/clients.ts @@ -0,0 +1,25 @@ +import { createClient as createDnsClient } from "@lumeweb/kernel-dns-client"; +import { createClient as createIpfsClient } from "@lumeweb/kernel-ipfs-client"; +import { createClient as createSwarmClient } from "@lumeweb/kernel-swarm-client"; +import { createClient as createPeerDiscoveryClient } from "@lumeweb/kernel-peer-discovery-client"; +import { createClient as createNetworkRegistryClient } from "@lumeweb/kernel-network-registry-client"; +import { createClient as createHandshakeClient } from "@lumeweb/kernel-handshake-client"; +import { createClient as createEthClient } from "@lumeweb/kernel-eth-client"; + +const dnsClient = createDnsClient(); +const ipfsClient = createIpfsClient(); +const swarmClient = createSwarmClient(); +const peerDiscoveryClient = createPeerDiscoveryClient(); +const networkRegistryClient = createNetworkRegistryClient(); +const handshakeClient = createHandshakeClient(); +const ethClient = createEthClient(); + +export { + dnsClient, + ipfsClient, + swarmClient, + peerDiscoveryClient, + networkRegistryClient, + handshakeClient, + ethClient, +}; diff --git a/src/contentProcessor.ts b/src/contentProcessor.ts new file mode 100644 index 0000000..d3f1f39 --- /dev/null +++ b/src/contentProcessor.ts @@ -0,0 +1,19 @@ +import { ContentFilter } from "./types.js"; + +export class ContentProcessor { + private filters: ContentFilter[] = []; + + registerFilter(filter: ContentFilter) { + this.filters.push(filter); + } + + async process(response: Response, mimeType: string): Promise { + let processedResponse = response; + + for (const filter of this.filters) { + processedResponse = await filter.process(processedResponse, mimeType); + } + + return processedResponse; + } +} diff --git a/src/filters/urlRewrite.ts b/src/filters/urlRewrite.ts new file mode 100644 index 0000000..6446322 --- /dev/null +++ b/src/filters/urlRewrite.ts @@ -0,0 +1,48 @@ +import { ContentFilter } from "src/types.js"; +import { getTld } from "@lumeweb/libresolver"; +import tldEnum from "@lumeweb/tld-enum"; +import * as cheerio from "cheerio"; + +export default class URLRewriteFilter implements ContentFilter { + async process(response: Response, mimeType: string): Promise { + if (mimeType !== "text/html") { + return response; + } + + let html = await response.text(); + + const $ = cheerio.load(html); + ["a", "link", "script", "img"].forEach((tag) => { + $.root() + .find(tag) + .each((index, element) => { + let attrName = ["a", "link"].includes(tag) ? "href" : "src"; + let urlValue = $(element).attr(attrName); + + if (urlValue) { + if (!isICANN(urlValue)) { + if (urlValue.startsWith("/")) { + $(element).attr(attrName, `/browse${urlValue}`); + } else if (urlValue.startsWith("http")) { + $(element).attr(attrName, `/browse/${urlValue}`); + } + } + } + }); + }); + + return new Response($.html(), { + headers: response.headers, + }); + } +} + +function isICANN(url: string) { + try { + const parsedUrl = new URL(url); + const domain = parsedUrl.hostname; + return tldEnum.list.includes(getTld(domain)); + } catch (e) { + return false; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6cc0011 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,94 @@ +import * as kernel from "@lumeweb/libkernel/kernel"; +import { + dnsClient, + ethClient, + handshakeClient, + ipfsClient, + networkRegistryClient, + peerDiscoveryClient, + swarmClient, +} from "./clients.js"; +import { ed25519 } from "@lumeweb/libkernel"; + +document.addEventListener("DOMContentLoaded", () => + document.getElementById("go-button")?.addEventListener("click", () => { + let input = ( + document.getElementById("address-bar") as HTMLInputElement + ).value.trim(); + + // If the input doesn't contain a protocol, assume it's http + if (!input.match(/^https?:\/\//)) { + input = `http://${input}`; + } + + try { + // Try to parse it as a URL + const url = new URL(input); + + // Update the iframe's src attribute + const iframe = document.getElementById( + "web-content", + ) as HTMLIFrameElement; + iframe.src = `/browse/${url.hostname}${url.pathname}${url.search}${url.hash}`; + } catch (e) { + // Handle invalid URLs here, if needed + console.error("Invalid URL:", e); + } + }), +); + +let BOOT_FUNCTIONS: (() => Promise)[] = []; + +async function boot() { + await kernel.init(); + await kernel.login(ed25519.utils.randomPrivateKey()); + + const reg = await navigator.serviceWorker.register("/sw.js"); + await reg.update(); + + await kernel.serviceWorkerReady(); + + BOOT_FUNCTIONS.push( + async () => + await swarmClient.addRelay( + "2d7ae1517caf4aae4de73c6d6f400765d2dd00b69d65277a29151437ef1c7d1d", + ), + ); + + // IRC + BOOT_FUNCTIONS.push( + async () => + await peerDiscoveryClient.register( + "zdiN5eJ3RfHpZHTYorGxBt1GCsrGJYV9GprwVWkj8snGsjWSrptFm8BtQX", + ), + ); + BOOT_FUNCTIONS.push( + async () => await networkRegistryClient.registerType("content"), + ); + BOOT_FUNCTIONS.push( + async () => await networkRegistryClient.registerType("blockchain"), + ); + BOOT_FUNCTIONS.push(async () => await handshakeClient.register()); + BOOT_FUNCTIONS.push(async () => await ethClient.register()); + BOOT_FUNCTIONS.push(async () => await ipfsClient.register()); + + const resolvers = [ + "zdiJdDdBJWAdYFTcRa9So5TQQ9f1pYMiMy4dqYcKp9imomQtR11LJUyJyV", // CID + "zdiKvnZYNjDqXaM8uF3pGEs7Tt6jqGc7t7M4eqbvJwpkTnrZymncfUW9Cj", // ENS + "zrjEH3iojPLr7986o7iCn9THBmJmHiuDWmS1G6oT8DnfuFM", // HNS + ]; + + for (const resolver of resolvers) { + BOOT_FUNCTIONS.push(async () => dnsClient.registerResolver(resolver)); + } + + await bootup(); +} + +async function bootup() { + for (const entry of Object.entries(BOOT_FUNCTIONS)) { + await entry[1](); + } +} + +document.addEventListener("DOMContentLoaded", boot); diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..642fb27 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,123 @@ +import exchangeCommunicationKeys from "./messages/exchangeCommunicationKeys.js"; +import { + deleteQuery, + getAuthStatus, + getAuthStatusKnown, + getLoggedInDefer, + getQueries, + getQuery, + resetLoggedInDefer, + setAuthStatus, + setAuthStatusKnown, + getAuthStatusDefer, +} from "./vars.js"; +const kernelMessageHandlers = { + exchangeCommunicationKeys, +}; + +export async function handleIncomingMessage(event: MessageEvent) { + if (event.source === null) { + return; + } + if (event.source === window) { + return; + } + + const data = event.data?.data; + if (event.data.method === "log") { + if (data?.isErr === false) { + console.log(data.message); + return; + } + console.error(data.message); + } + + if (event.data.method === "kernelAuthStatus") { + setAuthStatus(data); + if (!getAuthStatusKnown()) { + getAuthStatusDefer().resolve(); + setAuthStatusKnown(true); + console.log("bootloader is now initialized"); + if (!getAuthStatus().loginComplete) { + console.log("user is not logged in: waiting until login is confirmed"); + } else { + getLoggedInDefer().resolve(); + } + if (getAuthStatus().logoutComplete) { + resetLoggedInDefer(); + setAuthStatusKnown(false); + } + } + return; + } + + if (!("nonce" in event.data)) { + (event.source as WindowProxy).postMessage( + { + nonce: "N/A", + method: "response", + err: "message sent to kernel with no nonce", + }, + event.origin, + ); + return; + } + + if (!("method" in event.data)) { + (event.source as WindowProxy).postMessage( + { + nonce: event.data.nonce, + method: "response", + err: "message sent to kernel with no method", + }, + event.origin, + ); + return; + } + + if (event.data.method in kernelMessageHandlers) { + let response; + + try { + response = await kernelMessageHandlers[event.data.method]( + event.data.data, + ); + } catch (e: any) { + response = { err: (e as Error).message }; + } + + (event.source as WindowProxy).postMessage( + { + nonce: event.data.nonce, + data: response, + }, + event.origin, + ); + return; + } + if (!(event.data.nonce in getQueries())) { + return; + } + + let receiveResult = getQuery(event.data.nonce); + if (event.data.method === "response") { + deleteQuery(event.data.nonce); + } + + receiveResult?.(event.data); + + if (["moduleCall", "response"].includes(event.data.method)) { + return; + } + + (event.source as WindowProxy).postMessage( + { + nonce: event.data.nonce, + method: "response", + err: + "unrecognized method (user may need to log in): " + event.data.method, + }, + event.origin, + ); + return; +} diff --git a/src/messages/exchangeCommunicationKeys.ts b/src/messages/exchangeCommunicationKeys.ts new file mode 100644 index 0000000..ca1a7b2 --- /dev/null +++ b/src/messages/exchangeCommunicationKeys.ts @@ -0,0 +1,11 @@ +import { bytesToHex, hexToBytes } from "@lumeweb/libweb"; +import { + getCommunicationPubKey, + setFrontendCommunicationPubkey, +} from "../vars.js"; + +export default function (data: any) { + setFrontendCommunicationPubkey(hexToBytes(data)); + + return bytesToHex(getCommunicationPubKey()); +} diff --git a/src/mimes.ts b/src/mimes.ts new file mode 100644 index 0000000..a49866d --- /dev/null +++ b/src/mimes.ts @@ -0,0 +1,10 @@ +const extToMimes = new Map( + Object.entries({ + html: "text/html", + xhtml: "application/xhtml+xml", + xml: "application/xml", + }) +); +Object.freeze(extToMimes); + +export default extToMimes; diff --git a/src/providerManager.ts b/src/providerManager.ts new file mode 100644 index 0000000..e6f8af7 --- /dev/null +++ b/src/providerManager.ts @@ -0,0 +1,39 @@ +import { ContentProcessor } from "./contentProcessor.js"; +import { ContentProvider } from "./types.js"; +import { DNSRecord, DNSResult } from "@lumeweb/libresolver"; +import { URL } from "url"; + +export class ProviderManager { + private providers: ContentProvider[] = []; + + private _processor = new ContentProcessor(); + + get processor(): ContentProcessor { + return this._processor; + } + + register(provider: ContentProvider) { + this.providers.push(provider); + } + + async fetch(dnsResult: DNSResult, path: string): Promise { + for (const record of dnsResult.records) { + for (const provider of this.providers) { + if (provider.supports(record.value)) { + const content = await provider.fetchContent(record.value, path); + + if (content.headers.get("Content-Type")) { + return this._processor.process( + content, + content.headers.get("Content-Type")!, + ); + } + + return content; + } + } + } + + throw new Error("No suitable provider found."); + } +} diff --git a/src/providers/ipfs.ts b/src/providers/ipfs.ts new file mode 100644 index 0000000..325a54b --- /dev/null +++ b/src/providers/ipfs.ts @@ -0,0 +1,141 @@ +import { ContentProvider } from "src/types.js"; +import { ipfsPath, ipnsPath, path as checkPath } from "is-ipfs"; +import { createClient } from "@lumeweb/kernel-ipfs-client"; +import { CID } from "multiformats/cid"; +import type { UnixFSStats } from "@helia/unixfs"; +import * as nodePath from "path"; +import { fileTypeFromBuffer } from "file-type"; +import extToMimes from "../mimes.js"; +import { URL } from "url"; + +export default class IPFSProvider implements ContentProvider { + private _client = createClient(); + async fetchContent( + uri: string, + path: string, + query?: string, + ): Promise { + let cid = translatePath(uri); + let stat: UnixFSStats | null = null; + let urlPath = path; + const parsedPath = nodePath.parse(urlPath); + let err; + try { + if (ipnsPath(cid)) { + const cidHash = cid.replace("/ipns/", ""); + cid = await this._client.ipns(cidHash); + cid = `/ipfs/${cid}`; + } + + if (ipfsPath(cid)) { + cid = CID.parse(cid.replace("/ipfs/", "")).toV1().toString(); + stat = await this._client.stat(cid); + } + } catch (e) { + err = (e as Error).message; + } + + if (!err && stat?.type === "directory") { + if (!parsedPath.base.length || !parsedPath.ext.length) { + let found = false; + for (const indexFile of ["index.html", "index.htm"]) { + try { + const subPath = nodePath.join(urlPath, indexFile); + await this._client.stat(cid, { + path: subPath, + }); + urlPath = subPath; + found = true; + break; + } catch {} + } + + if (!found) { + err = "404"; + } + } else { + try { + await this._client.stat(cid, { + path: urlPath, + }); + } catch { + err = "404"; + } + } + + if (err) { + throw new Error(err); + } + } + + let bufferRead = 0; + const fileTypeBufferLength = 4100; + const mimeBuffer: Uint8Array[] = []; + let reader = await this._client.cat(cid, { path: urlPath }); + + for await (const chunk of reader.iterable()) { + if (bufferRead < fileTypeBufferLength) { + if (chunk.length >= fileTypeBufferLength) { + mimeBuffer.push(chunk.slice(0, fileTypeBufferLength)); + bufferRead += fileTypeBufferLength; + } else { + mimeBuffer.push(chunk); + bufferRead += chunk.length; + } + + if (bufferRead >= fileTypeBufferLength) { + reader.abort(); + break; + } + } else { + reader.abort(); + break; + } + } + + let mime; + + if (bufferRead >= fileTypeBufferLength) { + const totalLength = mimeBuffer.reduce((acc, val) => acc + val.length, 0); + const concatenated = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of mimeBuffer) { + concatenated.set(chunk, offset); + offset += chunk.length; + } + mime = await fileTypeFromBuffer(concatenated); + + if (!mime) { + const ext = nodePath.parse(urlPath).ext.replace(".", ""); + if (extToMimes.has(ext)) { + mime = extToMimes.get(ext); + } + } + } + + reader = await this._client.cat(cid, { path: urlPath }); + + const stream = new ReadableStream({ + async start(controller) { + for await (const chunk of reader.iterable()) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": mime ?? undefined, + }, + }); + } + + supports(uri: string): boolean { + return checkPath(translatePath(uri)); + } +} + +function translatePath(uri: string) { + return uri.replace(/:\/\//, "/").replace(/^/, "/"); +} diff --git a/src/sw.ts b/src/sw.ts new file mode 100644 index 0000000..55c841f --- /dev/null +++ b/src/sw.ts @@ -0,0 +1,69 @@ +import { createClient as createDnsClient } from "@lumeweb/kernel-dns-client"; +import { ProviderManager } from "./providerManager.js"; +import IPFSProvider from "./providers/ipfs.js"; +import URLRewriteFilter from "./filters/urlRewrite.js"; + +const dnsClient = createDnsClient(); + +const providerManager = new ProviderManager(); +providerManager.register(new IPFSProvider()); +providerManager.processor.registerFilter(new URLRewriteFilter()); + +globalThis.postMessage = async (...args) => { + // @ts-ignore + let ret = await clients.matchAll({ includeUncontrolled: true }); + ret.forEach((item) => item.postMessage(...args)); + + if (!ret.length) { + const cb = (event) => { + // @ts-ignore + postMessage(...args); + self.removeEventListener("activate", cb); + }; + self.addEventListener("activate", cb); + } +}; + +self.addEventListener("activate", (event) => { + // @ts-ignore + event.waitUntil( + (async () => { + // @ts-ignore + await clients.claim(); + // @ts-ignore + })(), + ); +}); + +addEventListener("fetch", (event: any) => { + event.respondWith( + (async () => { + const req = event.request; + const url = new URL(req.url); + + if ( + ["/index.html", "/index.js", "/"].includes(url.pathname) || + !url.pathname.startsWith("/browse/") + ) { + return fetch(event.request).then((response: any) => { + response.redirectToFinalURL = true; + return response; + }); + } + + let realUrl = url.pathname.replace(/^\/browse\//, "").replace(/\/$/, ""); + + if (!realUrl.match(/^https?:\/\//)) { + realUrl = `http://${realUrl}`; + } + // Use your existing communication framework to resolve DNS. + const dnsResult = await dnsClient.resolve(new URL(realUrl).hostname); + + if (!dnsResult.error && dnsResult.records.length > 0) { + return providerManager.fetch(dnsResult, new URL(realUrl).pathname); + } + + return new Response("Sorry, that is not a valid web3 website."); + })(), + ); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5ee1294 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,12 @@ +export interface ContentProvider { + supports: (uri: string) => boolean; + fetchContent: ( + uri: string, + path: string, + query?: string, + ) => Promise; +} + +export interface ContentFilter { + process: (response: Response, mineType: string) => Promise; +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..630a675 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,53 @@ +import { + getKernelLoaded, + getLoginComplete, + getLogoutComplete, +} from "./vars.js"; +import { objAsString } from "@lumeweb/libkernel"; + +export function sendAuthUpdate() { + window.parent.postMessage( + { + method: "kernelAuthStatus", + data: { + loginComplete: getLoginComplete(), + kernelLoaded: getKernelLoaded(), + logoutComplete: getLogoutComplete(), + }, + }, + "*", + ); +} + +function bootloaderWLog(isErr: boolean, ...inputs: any) { + // Build the message, each item gets its own line. We do this because items + // are often full objects. + let message = "[lumeweb-kernel-bootloader]"; + for (let i = 0; i < inputs.length; i++) { + message += "\n"; + message += objAsString(inputs[i]); + } + + // Create the log by sending it to the parent. + window.parent.postMessage( + { + method: "log", + data: { + isErr, + message, + }, + }, + "*", + ); +} + +export function log(...inputs: any) { + bootloaderWLog(false, ...inputs); +} +export function logErr(...inputs: any) { + bootloaderWLog(true, ...inputs); +} + +export function reloadKernel() { + window.location.reload(); +} diff --git a/src/vars.ts b/src/vars.ts new file mode 100644 index 0000000..5611c69 --- /dev/null +++ b/src/vars.ts @@ -0,0 +1,103 @@ +import { x25519 } from "@noble/curves/ed25519"; +import { defer } from "@lumeweb/libkernel/module"; +import { KernelAuthStatus } from "@lumeweb/libkernel"; + +let queriesNonce = 1; +let queries: any = {}; +let authStatus: KernelAuthStatus; +let authStatusKnown = false; +let authStatusDefer = defer(); +let loggedInDefer = defer(); + +const store = new Map( + Object.entries({ + loginComplete: false, + logoutComplete: false, + kernelLoaded: "not yet", + communicationKey: null, + frontendCommunicationPubKey: null, + }), +); + +export function setLoginComplete(status: boolean) { + store.set("loginComplete", status); +} +export function getLoginComplete(): boolean { + return store.get("loginComplete"); +} +export function setLogoutComplete(status: boolean) { + store.set("logoutComplete", status); +} +export function getLogoutComplete(): boolean { + return store.get("logoutComplete"); +} +export function setKernelLoaded(status: string) { + store.set("kernelLoaded", status); +} + +export function getKernelLoaded(): string { + return store.get("kernelLoaded"); +} + +export function getCommunicationKey(): Uint8Array { + if (!store.get("communicationKey")) { + store.set("communicationKey", x25519.utils.randomPrivateKey()); + } + + return store.get("communicationKey"); +} + +export function getCommunicationPubKey() { + return x25519.getPublicKey(getCommunicationKey()); +} + +export function getFrontendCommunicationPubkey(): Uint8Array { + return store.get("frontendCommunicationPubKey"); +} + +export function setFrontendCommunicationPubkey(key: Uint8Array) { + store.set("frontendCommunicationPubKey", key); +} +export function getAuthStatusDefer() { + return authStatusDefer; +} +export function getQueriesNonce(): number { + return queriesNonce; +} +export function addQuery(nonce: any, func: Function) { + queries[nonce] = func; +} + +export function increaseQueriesNonce() { + queriesNonce++; +} +export function setAuthStatus(status: KernelAuthStatus) { + authStatus = status; +} + +export function getAuthStatusKnown() { + return authStatusKnown; +} +export function setAuthStatusKnown(status: boolean) { + authStatusKnown = status; +} +export function getAuthStatus(): KernelAuthStatus { + return authStatus; +} +export function getLoggedInDefer() { + return loggedInDefer; +} +export function resetLoggedInDefer() { + loggedInDefer = defer(); +} +export function getQueries() { + return queries; +} + +export function getQuery(nonce: any) { + return queries[nonce]; +} + +export function deleteQuery(nonce: any) { + delete queries[nonce]; +}