/** * this is the default Gun+IPFS strategy plugin * for LibResilient. * * it uses Gun for content address resolution * and IPFS for delivery */ /** * this is apparently needed by Gun * `window` does not exist in ServiceWorker context */ if (typeof window === 'undefined') { var window = self; } /* ========================================================================= *\ |* === General stuff and setup === *| \* ========================================================================= */ // no polluting of the global namespace please (function () { var ipfs; var gun; var gunUser; // sane defaults let defaultConfig = { // name of this plugin // should not be changed name: "gun-ipfs", // Gun nodes to use gunNodes: ['https://gunjs.herokuapp.com/gun'], // the pubkey of the preconfigured Gun user; always needs to be set in config.js gunPubkey: 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.gunPubkey) !== "string" || config.gunPubkey === "") { let err = new Error("gunPubkey 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); self.log(config.name, `doImport()\n+-- ${args.join('\n+-- ')}`) if (typeof self.importScripts !== 'undefined') { self.log(config.name, `+-- self.importScripts.apply()`) self.importScripts.apply(self, args) } else { self.log( config.name, 'assuming these scripts are already included:\n', args.join('\n+-- ') ) } } async function setup_ipfs() { if (ipfs === undefined) { ipfs = false // we don't want to start a few times over self.log(config.name, 'importing IPFS-related libraries...'); doImport( "./lib/ipfs.js"); self.log(config.name, 'setting up IPFS...') ipfs = await self.Ipfs.create(); self.log(config.name, '+-- IPFS loaded :: ipfs is : ' + typeof ipfs) } } async function setup_gun() { self.log(config.name, 'setup_gun()') if (gun === undefined) { gun = false // we don't want to start a few times over self.log(config.name, 'importing Gun-related libraries...'); try { doImport( "./lib/gun.js", "./lib/sea.js", "./lib/webrtc.js"); } catch(e) { console.error(e) } self.log(config.name, 'setting up Gun...') gun = Gun(config.gunNodes); self.log(config.name, '+-- gun loaded :: gun is : ' + typeof gun); } if ( (gun !== false) && (gun !== undefined) ) { if (gunUser === undefined) { gunUser = false // we don't want to start a few times over self.log(config.name, 'setting up gunUser...') gunUser = gun.user(config.gunPubkey) self.log(config.name, '+-- gun init complete :: gunUser is: ' + typeof gunUser); } } else { console.error("at this point Gun should have been set up already, but isn't!") } } async function setup_gun_ipfs() { self.log(config.name, 'setup_gun_ipfs()') if (ipfs === undefined || gun === undefined) { self.log(config.name, 'setting up...') setup_ipfs(); setup_gun(); } else { self.log(config.name, 'setup already underway (ipfs: ' + ( (ipfs) ? 'done' : 'loading' ) + ', gun: ' + ( (gun) ? 'done' : 'loading' ) + ')') } } /* ========================================================================= *\ |* === Main functionality === *| \* ========================================================================= */ let getGunData = (gunaddr) => { return new Promise( (resolve, reject) => { self.log( config.name, 'getGunData()\n', `+-- gunUser : ${typeof gunUser}\n`, `+-- gunaddr[] : ${gunaddr}` ); // get the data gunUser .get(gunaddr[0]) .get(gunaddr[1]) .once(function(addr){ if (typeof addr !== 'undefined') { self.log(config.name, `IPFS address: "${addr}"`); resolve(addr); } else { // looks like we didn't get anything reject(new Error('IPFS address is undefined for: ' + gunaddr[1])) } // ToDo: what happens when we hit the timeout here? }, {wait: 5000}); } ); }; /** * the workhorse of this plugin */ async function getContentFromGunAndIPFS(url) { 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) === '/') { self.log(config.name, "path ends in '/', assuming 'index.html' should be appended."); gunaddr[1] += 'index.html'; } // inform self.log( config.name, `starting Gun lookup of: ${gunaddr.join(', ')}\n`, `+-- gun : ${typeof gun}\n`, `+-- 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; } self.log(config.name, " +-- guessed contentType : " + contentType); return getGunData(gunaddr).then(ipfsaddr => { self.log(config.name, `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)=>{ self.log(config.name, `got a Gun-addressed IPFS-stored file: ${file.value.path}\n+-- 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 Promise((resolve, reject) => { self.log(config.name, "adding to IPFS...") self.log(config.name, "+-- number of resources:", resources.length) var ipfs_addresses = {}; resources.forEach(function(res){ self.log(config.name, " +-- 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[abs_path] = '/ipfs/' + result.cid.string self.log(config.name, "added to IPFS: " + abs_path + ' as ' + ipfs_addresses[abs_path]) // if we seem to have all we need, resolve! if (Object.keys(ipfs_addresses).length === 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 Promise((resolve, reject) => { self.log(config.name, 'checking IPFS content against a gateway...') self.log(config.name, '+-- gateway in use: ' + config.ipfsGateway) // get the list of IPFS addresses var updatedPaths = Object.values(ipfs_addresses) for (path in ipfs_addresses) { // start the fetch fetch(config.ipfsGateway + ipfs_addresses[path]) .then((response) => { ipfsaddr = response.url.replace(config.ipfsGateway, '') if (response.ok) { self.log(config.name, '+-- verified: ' + ipfsaddr) var pathIndex = updatedPaths.indexOf(ipfsaddr) if (pathIndex > -1) { updatedPaths.splice(pathIndex, 1) } if (updatedPaths.length === 0) { self.log(config.name, '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) }) } }) } /** * auth a Gun admin user * (and verify it's the correct one with regards to the configured config.gunPubkey) */ let authGunAdmin = (user, pass) => { return new Promise((resolve, reject) => { // we need a separate Gun instance, otherwise gu will get merged with gunUser // and we want these to be separate var g = Gun(config.gunNodes) var gu = g.user() gu.auth(user, pass, (userReference) => { if (userReference.err) { reject(new Error(userReference.err)) // reality check -- does it match our preconfigured pubkey? } else if (gu._.soul.slice(1) === config.gunPubkey) { self.log(config.name, 'Gun admin user authenticated using password.'); // we need to keep the reference to g, otherwise gu becomes unusable var gApi = { user: gu, gun: g } resolve(gApi) } else { reject(new Error('Password-authenticated user does not match preconfigured pubkey!')) } }) }) } /** * add IPFS addresses to Gun */ let addToGun = (user, pass, ipfs_addresses) => { // we need an authenticated Gun user return authGunAdmin(user, pass) .then((gunAPI) => { self.log(config.name, '+-- adding new IPFS addresses to Gun...') gunAPI.user.get(window.location.host).put(ipfs_addresses /*, function(ack) {...}*/); return gunAPI; }) /** * regular confirmations don't seem to work * * so instead we're using the regular read-only Gun user * to .get() the data that we've .put() just a minute ago * * we then subscribe to the .on() events and once we notice the correct * addresseswe consider our job done and quit. */ .then((gunAPI) => { // get the paths self.log(config.name, '+-- starting verification of updated Gun data...') var updatedPaths = Object.keys(ipfs_addresses) for (path in ipfs_addresses) { self.log(config.name, ' +-- watching: ' + path) //debuglog('watching path for updates:', path) // using the global gunUser to check if updates propagated gunUser.get(window.location.host).get(path).on(function(updaddr, updpath){ /*debuglog('+--', updpath) debuglog(' updated :', ipfs_addresses[updpath]) debuglog(' received :', updaddr)*/ if (ipfs_addresses[updpath] == updaddr) { // update worked! gunUser.get(window.location.host).get(updpath).off() self.log(config.name, '+-- update confirmed for:', updpath, '[' + updaddr + ']') var pathIndex = updatedPaths.indexOf(updpath) if (pathIndex > -1) { updatedPaths.splice(pathIndex, 1) } if (updatedPaths.length === 0) { self.log(config.name, 'all updates confirmed successful!') return true; } } }) } }) } /** * 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) => { 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_gun_ipfs(); // and add ourselves to it // with some additional metadata self.LibResilientPlugins.push({ name: config.name, description: 'Decentralized resource fetching using Gun for address resolution and IPFS for content delivery.', version: 'COMMIT_UNKNOWN', fetch: getContentFromGunAndIPFS, publish: publishContent }) // done with not poluting the global namespace })()