diff --git a/LICENSE b/LICENSE index 13a5b6e..8995c8b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Lume Web +Copyright (c) 2022 Hammer Technologies LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/build.js b/build.js new file mode 100644 index 0000000..c6efc0b --- /dev/null +++ b/build.js @@ -0,0 +1,9 @@ +import esbuild from 'esbuild' + +esbuild.buildSync({ + entryPoints: ['src/index.ts'], + outfile: 'dist/ipfs.js', + format: 'cjs', + bundle: true, + platform: "node" +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd321ad --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@lumeweb/relay-plugin-ipfs", + "type": "module", + "version": "0.1.0", + "scripts": { + "build": "node build.js" + }, + "devDependencies": { + "@lumeweb/relay-types": "https://github.com/LumeWeb/relay-types.git", + "esbuild": "^0.15.5" + }, + "dependencies": { + "ipfs-core": "^0.15.4", + "ipfs-http-response": "^3.0.4", + "multiformats": "^9.7.1" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7c7ba09 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,227 @@ +import type { + Plugin, + PluginAPI, + RPCRequest, + RPCResponse, +} from "@lumeweb/relay-types"; + +import { CID } from "multiformats/cid"; +// @ts-ignore +import toStream from "it-to-stream"; +import type { StatResult } from "ipfs-core/dist/src/components/files/stat"; +import * as IPFS from "ipfs-core"; + +interface StatFileResponse { + exists: boolean; + contentType: string | null; + error: any; + directory: boolean; + files: StatFileSubfile[]; + timeout: boolean; + size: number; +} + +interface StatFileSubfile { + name: string; + size: number; +} + +let client: IPFS.IPFS; + +import { utils } from "ipfs-http-response"; + +const { detectContentType } = utils; + +function normalizeCidPath(path: any) { + if (path instanceof Uint8Array) { + return CID.decode(path).toString(); + } + + path = path.toString(); + + if (path.indexOf("/ipfs/") === 0) { + path = path.substring("/ipfs/".length); + } + + if (path.charAt(path.length - 1) === "/") { + path = path.substring(0, path.length - 1); + } + + return path; +} + +function normalizePath( + hash?: string, + path?: string, + fullPath?: string +): string { + if (!fullPath) { + if (!path) { + path = "/"; + } + + fullPath = `${hash}/${path}`; + } + + fullPath = fullPath.replace(/\/{2,}/, "/"); + return normalizeCidPath(fullPath); +} + +async function fetchFile( + hash?: string, + path?: string, + fullPath?: string +): Promise> { + let data = await fileExists(hash, path, fullPath); + + if (data instanceof Error) { + return data; + } + + if (data?.type === "directory") { + return new Error("ERR_HASH_IS_DIRECTORY"); + } + + return client.cat(data.cid); +} + +async function statFile(hash?: string, path?: string, fullPath?: string) { + let stats: StatFileResponse = { + exists: false, + contentType: null, + error: null, + directory: false, + files: [], + timeout: false, + size: 0, + }; + + let exists = await fileExists(hash, path, fullPath); + fullPath = normalizePath(hash, path, fullPath); + + if (exists instanceof Error) { + stats.error = exists.toString(); + + if (exists.message.includes("aborted")) { + stats.timeout = true; + } + + return stats; + } + stats.exists = true; + + if (exists?.type === "directory") { + stats.directory = true; + for await (const item of client.ls(exists.cid)) { + stats.files.push({ + name: item.name, + size: item.size, + } as StatFileSubfile); + } + return stats; + } + + const { size } = await client.files.stat(`/ipfs/${exists.cid}`); + stats.size = size; + + const { contentType } = await detectContentType( + fullPath, + client.cat(exists.cid) + ); + stats.contentType = contentType ?? null; + + return stats; +} + +async function fileExists( + hash?: string, + path?: string, + fullPath?: string +): Promise { + client = client as IPFS.IPFS; + let ipfsPath = normalizePath(hash, path, fullPath); + try { + const ret = await client.files.stat(`/ipfs/${ipfsPath}`); + return ret; + } catch (err: any) { + return err; + } +} + +async function resolveIpns( + hash: string, + path: string +): Promise { + for await (const result of client.name.resolve(hash)) { + return normalizePath(undefined, undefined, `${result}/${path}`); + } + + return false; +} + +const plugin: Plugin = { + name: "ipfs", + async plugin(api: PluginAPI): Promise { + client = await IPFS.create(); + api.registerMethod("stat_ipfs", { + cacheable: false, + async handler(request: RPCRequest): Promise { + return await statFile(request.data?.hash, request.data?.path); + }, + }); + api.registerMethod("stat_ipns", { + cacheable: false, + async handler(request: RPCRequest): Promise { + let ipfsPath = await resolveIpns( + request.data?.hash, + request.data?.path + ); + if (!ipfsPath) { + throw new Error("ipns lookup failed"); + } + return statFile(undefined, undefined, ipfsPath as string); + }, + }); + api.registerMethod("fetch_ipfs", { + cacheable: false, + async handler( + request: RPCRequest, + sendStream: (stream: AsyncIterable) => void + ): Promise { + const ret = await fetchFile(request.data?.hash, request.data?.path); + if (ret instanceof Error) { + throw ret; + } + + sendStream(ret); + + return null; + }, + }); + api.registerMethod("fetch_ipns", { + cacheable: false, + async handler( + request: RPCRequest, + sendStream: (stream: AsyncIterable) => void + ): Promise { + let ipfsPath = await resolveIpns( + request.data?.hash, + request.data?.path + ); + if (!ipfsPath) { + throw new Error("ipns lookup failed"); + } + const ret = await fetchFile(undefined, undefined, ipfsPath as string); + if (ret instanceof Error) { + throw ret; + } + + sendStream(ret); + + return null; + }, + }); + }, +}; + +export default plugin; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f6a7059 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "commonjs", + "moduleResolution": "node", + "rootDir": "src", + "outDir": "dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +}