diff --git a/__tests__/plugins/ipns-ipfs.test.js b/__tests__/plugins/ipns-ipfs.test.js index e85eede..a556282 100644 --- a/__tests__/plugins/ipns-ipfs.test.js +++ b/__tests__/plugins/ipns-ipfs.test.js @@ -4,17 +4,19 @@ describe("plugin: ipns-ipfs", () => { beforeEach(() => { Object.assign(global, makeServiceWorkerEnv()); jest.resetModules(); - self.LibResilientPlugins = new Array() - self.LibResilientConfig = { - plugins: { - 'ipns-ipfs':{ - 'ipnsPubkey': 'stub' - } + global.LibResilientPluginConstructors = new Map() + init = { + name: 'ipns-ipfs', + ipnsPubkey: 'stub' + } + LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) } } }) test("it should register in LibResilientPlugins", () => { require("../../plugins/ipns-ipfs.js"); - expect(self.LibResilientPlugins[0].name).toEqual('ipns-ipfs'); + expect(LibResilientPluginConstructors.get('ipns-ipfs')(LR, init).name).toEqual('ipns-ipfs'); }); }); diff --git a/plugins/ipns-ipfs.js b/plugins/ipns-ipfs.js index 8c60d06..c7bc44a 100644 --- a/plugins/ipns-ipfs.js +++ b/plugins/ipns-ipfs.js @@ -17,328 +17,329 @@ \* ========================================================================= */ // no polluting of the global namespace please -(function () { +(function(LRPC){ + // this never changes + const pluginName = "ipns-ipfs" + LRPC.set(pluginName, (LR, init={})=>{ - var ipfs; + var ipfs; - // sane defaults - let defaultConfig = { - // name of this plugin - // should not be changed - name: "ipns-ipfs", - // the pubkey of the preconfigured IPNS node; always needs to be set in config.js - ipnsPubkey: null, - // the IPFS gateway we're using for verification when publishing; default is usually ok - ipfsGateway: 'https://gateway.ipfs.io' - } - - // merge the defaults with settings from LibResilientConfig - let config = {...defaultConfig, ...self.LibResilientConfig.plugins[defaultConfig.name]} - - // reality check: Gun pubkey needs to be set to a non-empty string - if (typeof(config.ipnsPubkey) !== "string" || config.ipnsPubkey === "") { - let err = new Error("ipnsPubkey not confgured") - console.error(err) - throw err - } - - /** - * importing stuff works differently between a browser window context - * and a ServiceWorker context, because things just can't be easy and sane - */ - function doImport() { - var args = Array.prototype.slice.call(arguments); - if (typeof self.importScripts !== 'undefined') { - self.importScripts.apply(self, args) - } else { - console.log('(COMMIT_UNKNOWN) assuming these scripts are already included:') - args.forEach(function(src){ - console.log('+--', src) - }) + // sane defaults + let defaultConfig = { + // the pubkey of the preconfigured IPNS node; always needs to be set in config.js + ipnsPubkey: null, + // the IPFS gateway we're using for verification when publishing; default is usually ok + ipfsGateway: 'https://gateway.ipfs.io' } - } - async function setup_ipfs() { - if (ipfs === undefined) { - ipfs = false // we don't want to start a few times over - console.log('(COMMIT_UNKNOWN) Importing IPFS-related libraries...'); - doImport( - "./lib/ipfs.js"); - console.log('(COMMIT_UNKNOWN) Setting up IPFS...') - try { - ipfs = await self.Ipfs.create({ - config: { - dht: { - enabled: true, - clientMode: true - } - }, - libp2p: { + // merge the defaults with settings from init + let config = {...defaultConfig, ...init} + + // reality check: Gun pubkey needs to be set to a non-empty string + if (typeof(config.ipnsPubkey) !== "string" || config.ipnsPubkey === "") { + let err = new Error("ipnsPubkey not confgured") + console.error(err) + throw err + } + + /** + * importing stuff works differently between a browser window context + * and a ServiceWorker context, because things just can't be easy and sane + */ + function doImport() { + var args = Array.prototype.slice.call(arguments); + if (typeof self.importScripts !== 'undefined') { + self.importScripts.apply(self, args) + } else { + console.log('(COMMIT_UNKNOWN) assuming these scripts are already included:') + args.forEach(function(src){ + console.log('+--', src) + }) + } + } + + async function setup_ipfs() { + if (ipfs === undefined) { + ipfs = false // we don't want to start a few times over + console.log('(COMMIT_UNKNOWN) Importing IPFS-related libraries...'); + doImport( + "./lib/ipfs.js"); + console.log('(COMMIT_UNKNOWN) Setting up IPFS...') + try { + ipfs = await self.Ipfs.create({ config: { dht: { enabled: true, clientMode: true } + }, + libp2p: { + config: { + dht: { + enabled: true, + clientMode: true + } + } } - } - }); - console.log('+-- IPFS loaded :: ipfs is : ' + typeof ipfs) - } catch(e) { - console.error('+-- Error loading IPFS: ' + e) - } - } - } - - /* ========================================================================= *\ - |* === Main functionality === *| - \* ========================================================================= */ - - /** - * the workhorse of this plugin - */ - async function getContentFromIPNSAndIPFS(url) { - return new Error("Not implemented yet.") - - var urlArray = url.replace(/https?:\/\//, '').split('/') - var gunaddr = [urlArray[0], '/' + urlArray.slice(1).join('/')] - - /* - * if the gunaddr[1] ends in '/', append 'index.html' to it - */ - if (gunaddr[1].charAt(gunaddr[1].length - 1) === '/') { - console.log("NOTICE: address ends in '/', assuming '/index.html' should be appended."); - gunaddr[1] += 'index.html'; - } - - console.log("2. Starting Gun lookup of: '" + gunaddr.join(', ') + "'"); - console.log(" +-- gun : " + typeof gun); - console.log(" +-- gunUser : " + typeof gunUser); - - /* - * naïvely assume content type based on file extension - * TODO: this needs a fix - */ - var contentType = ''; - switch (gunaddr.slice(-1)[0].split('.', -1)[1].toLowerCase()) { - case 'html': - case 'htm': - contentType = 'text/html'; - break; - case 'css': - contentType = 'text/css'; - break; - case 'js': - contentType = 'text/javascript'; - break; - case 'svg': - contentType = 'image/svg+xml'; - break; - case 'ico': - contentType = 'image/x-icon'; - break; - } - console.log(" +-- guessed contentType : " + contentType); - - return getGunData(gunaddr).then(ipfsaddr => { - console.log("3. Starting IPFS lookup of: '" + ipfsaddr + "'"); - return ipfs.get(ipfsaddr).next(); - }).then(file => { - // we only need one - if (file.value.content) { - async function getContent(source) { - var content = new Uint8Array() - var data = await source.next() - while (! data.done) { - var newContent = new Uint8Array(content.length + data.value.length); - newContent.set(content) - newContent.set(data.value, content.length) - content = newContent - data = await source.next() - } - return content + }); + console.log('+-- IPFS loaded :: ipfs is : ' + typeof ipfs) + } catch(e) { + console.error('+-- Error loading IPFS: ' + e) } - return getContent(file.value.content).then((content)=>{ - console.log('4. Got a Gun-addressed IPFS-stored file: ' + file.value.path + '; content is: ' + typeof content); - // creating and populating the blob - var blob = new Blob( - [content], - {'type': contentType} - ); - - return new Response( - blob, - { - 'status': 200, - 'statusText': 'OK', - 'headers': { - 'Content-Type': contentType, - 'ETag': file.value.path, - 'X-LibResilient-Method': config.name, - 'X-LibResilient-ETag': file.value.path - } - } - ); - }) - }; - }); - } - - - /* ========================================================================= *\ - |* === Publishing stuff === *| - \* ========================================================================= */ - /* - * these are used for adding content to IPFS and Gun - */ - - - /** - * adding stuff to IPFS - * accepts an array of URLs - * - * returns a Promise that resolves to an object mapping URLs to IPFS hashes - */ - let addToIPFS = (resources) => { - return new Error("Not implemented yet.") - - return new Promise((resolve, reject) => { - - console.log("Adding to IPFS...") - console.log("+-- number of resources:", resources.length) - var ipfs_addresses = new Map(); - - resources.forEach(function(res){ - console.log(" +-- handling internal resource:", res) - - ipfs.add(Ipfs.urlSource(res)) - .then((result) => { - // add to the list -- this is needed to add stuff to Gun - // result.path is just the filename stored in IPFS, not the actual path! - // res holds the full URL - // what we need in ipfs_addresses is in fact the absolute path (no domain, no scheme) - var abs_path = res.replace(window.location.origin, '') - ipfs_addresses.set(abs_path, '/ipfs/' + result.cid.string) - console.log("Added to IPFS: " + abs_path + ' as ' + ipfs_addresses.get(abs_path)) - // if we seem to have all we need, resolve! - if (ipfs_addresses.size === resources.length) resolve(ipfs_addresses); - }) - - }); - }) - } - - /** - * verification that content pushed to IPFS - * is, in fact, available in IPFS - * - * a nice side-effect is that this will pre-load the content on - * a gateway, which tends to be a large (and fast) IPFS node - * - * this is prety naïve, in that it pulls the content from an ipfs gateway - * and assumes all is well if it get a HTTP 200 and any content - * - * that is, it does *not* check that the content matches what was pushed - * we trust IPFS here, I guess - * - * finally, we're using a regular fetch() instead of just going through our - * ipfs object because our IPFS object might have things cached and we want - * to test a completey independent route - * - * takes a object mapping paths to IPFS addresses - * and returns a Promise that resolves to true - */ - let verifyInIPFS = (ipfs_addresses) => { - return new Error("Not implemented yet.") - - return new Promise((resolve, reject) => { - console.log('Checking IPFS content against a gateway...') - console.log('+-- gateway in use: ' + config.ipfsGateway) - // get the list of IPFS addresses - var updatedPaths = Array.from(ipfs_addresses.values()) - for (const path of ipfs_addresses.keys()) { - // start the fetch - fetch(config.ipfsGateway + ipfs_addresses.get(path)) - .then((response) => { - ipfsaddr = response.url.replace(config.ipfsGateway, '') - if (response.ok) { - console.log('+-- verified: ' + ipfsaddr) - var pathIndex = updatedPaths.indexOf(ipfsaddr) - if (pathIndex > -1) { - updatedPaths.splice(pathIndex, 1) - } - if (updatedPaths.length === 0) { - console.log('All updates confirmed successful!') - resolve(ipfs_addresses); - } - } else { - reject(new Error('HTTP error (' + response.status + ' ' + response.statusText + ' for: ' + ipfsaddr)) - } - }) - .catch((err) => { - // it would be nice to have the failed path here somehow - // alternatively, updating updatedPaths with info on failed - // requests might work - reject(err) - }) } - }) - } - - - /** - * example code for of adding content to IPFS, verifying it was successfully added, - * and adding the new addresses to Gun (and verifying changes propagated) - * - * TODO: this should accept a URL, a Response, or a list of URLs, - * and handle stuff appropriately - */ - let publishContent = (resource, user, password) => { - return new Error("Not implemented yet.") - - if (typeof resource === 'string') { - // we need this as an array of strings - resource = [resource] - } else if (typeof resource === 'object') { - if (!Array.isArray(resource)) { - // TODO: this needs to be implemented such that the Response is used directly - // but that would require all called functions to also accept a Response - // and act accordingly; #ThisIsComplicated - throw new Error("Handling a Response: not implemented yet") - } - } else { - // everything else -- that's a paddlin'! - throw new TypeError("Only accepts: string, Array of string, Response.") } + + /* ========================================================================= *\ + |* === Main functionality === *| + \* ========================================================================= */ + + /** + * the workhorse of this plugin + */ + async function getContentFromIPNSAndIPFS(url) { + return new Error("Not implemented yet.") - // add to IPFS - var ipfsPromise = addToIPFS(resource) - return Promise.all([ - // verify stuff ended up in IPFS - ipfsPromise.then(verifyInIPFS), - // add to Gun and verify Gun updates propagation - ipfsPromise.then((hashes) => { - addToGun(user, password, hashes) + var urlArray = url.replace(/https?:\/\//, '').split('/') + var gunaddr = [urlArray[0], '/' + urlArray.slice(1).join('/')] + + /* + * if the gunaddr[1] ends in '/', append 'index.html' to it + */ + if (gunaddr[1].charAt(gunaddr[1].length - 1) === '/') { + console.log("NOTICE: address ends in '/', assuming '/index.html' should be appended."); + gunaddr[1] += 'index.html'; + } + + console.log("2. Starting Gun lookup of: '" + gunaddr.join(', ') + "'"); + console.log(" +-- gun : " + typeof gun); + console.log(" +-- gunUser : " + typeof gunUser); + + /* + * naïvely assume content type based on file extension + * TODO: this needs a fix + */ + var contentType = ''; + switch (gunaddr.slice(-1)[0].split('.', -1)[1].toLowerCase()) { + case 'html': + case 'htm': + contentType = 'text/html'; + break; + case 'css': + contentType = 'text/css'; + break; + case 'js': + contentType = 'text/javascript'; + break; + case 'svg': + contentType = 'image/svg+xml'; + break; + case 'ico': + contentType = 'image/x-icon'; + break; + } + console.log(" +-- guessed contentType : " + contentType); + + return getGunData(gunaddr).then(ipfsaddr => { + console.log("3. Starting IPFS lookup of: '" + ipfsaddr + "'"); + return ipfs.get(ipfsaddr).next(); + }).then(file => { + // we only need one + if (file.value.content) { + async function getContent(source) { + var content = new Uint8Array() + var data = await source.next() + while (! data.done) { + var newContent = new Uint8Array(content.length + data.value.length); + newContent.set(content) + newContent.set(data.value, content.length) + content = newContent + data = await source.next() + } + return content + } + return getContent(file.value.content).then((content)=>{ + console.log('4. Got a Gun-addressed IPFS-stored file: ' + file.value.path + '; content is: ' + typeof content); + // creating and populating the blob + var blob = new Blob( + [content], + {'type': contentType} + ); + + return new Response( + blob, + { + 'status': 200, + 'statusText': 'OK', + 'headers': { + 'Content-Type': contentType, + 'ETag': file.value.path, + 'X-LibResilient-Method': pluginName, + 'X-LibResilient-ETag': file.value.path + } + } + ); + }) + }; + }); + } + + + /* ========================================================================= *\ + |* === Publishing stuff === *| + \* ========================================================================= */ + /* + * these are used for adding content to IPFS and Gun + */ + + + /** + * adding stuff to IPFS + * accepts an array of URLs + * + * returns a Promise that resolves to an object mapping URLs to IPFS hashes + */ + let addToIPFS = (resources) => { + return new Error("Not implemented yet.") + + return new Promise((resolve, reject) => { + + console.log("Adding to IPFS...") + console.log("+-- number of resources:", resources.length) + var ipfs_addresses = new Map(); + + resources.forEach(function(res){ + console.log(" +-- handling internal resource:", res) + + ipfs.add(Ipfs.urlSource(res)) + .then((result) => { + // add to the list -- this is needed to add stuff to Gun + // result.path is just the filename stored in IPFS, not the actual path! + // res holds the full URL + // what we need in ipfs_addresses is in fact the absolute path (no domain, no scheme) + var abs_path = res.replace(window.location.origin, '') + ipfs_addresses.set(abs_path, '/ipfs/' + result.cid.string) + console.log("Added to IPFS: " + abs_path + ' as ' + ipfs_addresses.get(abs_path)) + // if we seem to have all we need, resolve! + if (ipfs_addresses.size === resources.length) resolve(ipfs_addresses); + }) + + }); }) - ]) - } + } - /* ========================================================================= *\ - |* === Initialization === *| - \* ========================================================================= */ - - // we probably need to handle this better - setup_ipfs(); + /** + * verification that content pushed to IPFS + * is, in fact, available in IPFS + * + * a nice side-effect is that this will pre-load the content on + * a gateway, which tends to be a large (and fast) IPFS node + * + * this is prety naïve, in that it pulls the content from an ipfs gateway + * and assumes all is well if it get a HTTP 200 and any content + * + * that is, it does *not* check that the content matches what was pushed + * we trust IPFS here, I guess + * + * finally, we're using a regular fetch() instead of just going through our + * ipfs object because our IPFS object might have things cached and we want + * to test a completey independent route + * + * takes a object mapping paths to IPFS addresses + * and returns a Promise that resolves to true + */ + let verifyInIPFS = (ipfs_addresses) => { + return new Error("Not implemented yet.") + + return new Promise((resolve, reject) => { + console.log('Checking IPFS content against a gateway...') + console.log('+-- gateway in use: ' + config.ipfsGateway) + // get the list of IPFS addresses + var updatedPaths = Array.from(ipfs_addresses.values()) + for (const path of ipfs_addresses.keys()) { + // start the fetch + fetch(config.ipfsGateway + ipfs_addresses.get(path)) + .then((response) => { + ipfsaddr = response.url.replace(config.ipfsGateway, '') + if (response.ok) { + console.log('+-- verified: ' + ipfsaddr) + var pathIndex = updatedPaths.indexOf(ipfsaddr) + if (pathIndex > -1) { + updatedPaths.splice(pathIndex, 1) + } + if (updatedPaths.length === 0) { + console.log('All updates confirmed successful!') + resolve(ipfs_addresses); + } + } else { + reject(new Error('HTTP error (' + response.status + ' ' + response.statusText + ' for: ' + ipfsaddr)) + } + }) + .catch((err) => { + // it would be nice to have the failed path here somehow + // alternatively, updating updatedPaths with info on failed + // requests might work + reject(err) + }) + } + }) + } - // and add ourselves to it - // with some additional metadata - self.LibResilientPlugins.push({ - name: config.name, - description: 'Decentralized resource fetching using IPNS for address resolution and IPFS for content delivery.', - version: 'COMMIT_UNKNOWN', - fetch: getContentFromIPNSAndIPFS, - publish: publishContent + /** + * example code for of adding content to IPFS, verifying it was successfully added, + * and adding the new addresses to Gun (and verifying changes propagated) + * + * TODO: this should accept a URL, a Response, or a list of URLs, + * and handle stuff appropriately + */ + let publishContent = (resource, user, password) => { + return new Error("Not implemented yet.") + + if (typeof resource === 'string') { + // we need this as an array of strings + resource = [resource] + } else if (typeof resource === 'object') { + if (!Array.isArray(resource)) { + // TODO: this needs to be implemented such that the Response is used directly + // but that would require all called functions to also accept a Response + // and act accordingly; #ThisIsComplicated + throw new Error("Handling a Response: not implemented yet") + } + } else { + // everything else -- that's a paddlin'! + throw new TypeError("Only accepts: string, Array of string, Response.") + } + + // add to IPFS + var ipfsPromise = addToIPFS(resource) + return Promise.all([ + // verify stuff ended up in IPFS + ipfsPromise.then(verifyInIPFS), + // add to Gun and verify Gun updates propagation + ipfsPromise.then((hashes) => { + addToGun(user, password, hashes) + }) + ]) + } + + /* ========================================================================= *\ + |* === Initialization === *| + \* ========================================================================= */ + + // we probably need to handle this better + setup_ipfs(); + + + // and add ourselves to it + // with some additional metadata + return { + name: pluginName, + description: 'Decentralized resource fetching using IPNS for address resolution and IPFS for content delivery.', + version: 'COMMIT_UNKNOWN', + fetch: getContentFromIPNSAndIPFS, + publish: publishContent + } + }) - // done with not polluting the global namespace -})() +})(LibResilientPluginConstructors)