libresilient/plugins/gun-ipfs/index.js

511 wiersze
21 KiB
JavaScript

/**
* 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(LRPC){
// this never changes
const pluginName = "gun-ipfs"
LRPC.set(pluginName, (LR, init={})=>{
var ipfs;
var gun;
var gunUser;
// sane defaults
let defaultConfig = {
// Gun nodes to use
gunNodes: ['https://gunjs.herokuapp.com/gun'],
// the pubkey of the preconfigured Gun user; always needs to be set in config.json
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 init
let config = {...defaultConfig, ...init}
// 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);
LR.log(pluginName, `doImport()\n+-- ${args.join('\n+-- ')}`)
if (typeof self.importScripts !== 'undefined') {
LR.log(pluginName, `+-- self.importScripts.apply()`)
self.importScripts.apply(self, args)
} else {
LR.log(
pluginName,
'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
LR.log(pluginName, 'importing IPFS-related libraries...');
doImport(
"./lib/ipfs.js");
LR.log(pluginName, 'setting up IPFS...')
try {
ipfs = await self.Ipfs.create();
LR.log(pluginName, '+-- IPFS loaded :: ipfs is : ' + typeof ipfs)
} catch(e) {
console.error('+-- Error loading IPFS: ' + e)
}
}
}
async function setup_gun() {
LR.log(pluginName, 'setup_gun()')
if (gun === undefined) {
gun = false // we don't want to start a few times over
LR.log(pluginName, 'importing Gun-related libraries...');
try {
doImport(
"./lib/gun.js",
"./lib/sea.js",
"./lib/webrtc.js");
} catch(e) {
console.error(e)
}
LR.log(pluginName, 'setting up Gun...')
try {
gun = Gun(config.gunNodes);
LR.log(pluginName, '+-- gun loaded :: gun is : ' + typeof gun);
} catch(e) {
LR.log(pluginName, 'Error setting up Gun: ' + e);
}
}
if ( (gun !== false) && (gun !== undefined) ) {
if (gunUser === undefined) {
gunUser = false // we don't want to start a few times over
LR.log(pluginName, 'setting up gunUser...')
gunUser = gun.user(config.gunPubkey)
LR.log(pluginName, '+-- 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() {
LR.log(pluginName, 'setup_gun_ipfs()')
if (ipfs === undefined || gun === undefined) {
LR.log(pluginName, 'setting up...')
await setup_ipfs();
await setup_gun();
} else {
LR.log(pluginName, 'setup already underway (ipfs: ' + ( (ipfs) ? 'done' : 'loading' ) + ', gun: ' + ( (gun) ? 'done' : 'loading' ) + ')')
}
}
/* ========================================================================= *\
|* === Main functionality === *|
\* ========================================================================= */
let getGunData = (gunaddr) => {
return new Promise(
(resolve, reject) => {
LR.log(
pluginName,
'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') {
LR.log(pluginName, `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, init={}) {
await setup_gun_ipfs();
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) === '/') {
LR.log(pluginName, "path ends in '/', assuming 'index.html' should be appended.");
gunaddr[1] += 'index.html';
}
// inform
LR.log(
pluginName,
`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 'json':
contentType = 'application/json';
break;
case 'svg':
contentType = 'image/svg+xml';
break;
case 'ico':
contentType = 'image/x-icon';
break;
}
LR.log(pluginName, " +-- guessed contentType : " + contentType);
const ipfsaddr = await getGunData(gunaddr)
LR.log(pluginName, `starting IPFS lookup of: '${ipfsaddr}'`);
LR.log(pluginName, `ipfs is: '${ipfs}'`);
const file = await ipfs.get(ipfsaddr).next()
if(file.value.content) {
let source = file.value.content
let chunks = []
let contentLength = 0;
let data = await source.next()
while(!data.done) {
chunks.push(data.value)
contentLength += data.value.length
data = await source.next()
}
LR.log(pluginName, `Chunks: ${chunks}`)
let content = new Uint8Array(contentLength)
let currentLength = 0;
for(let i = 0; i < chunks.length; i++) {
content.set(chunks[i], currentLength)
currentLength += chunks[i].length;
}
LR.log(pluginName, `got a Gun-addressed IPFS-stored file: ${file.value.path}\n+-- content is: ${typeof content}`);
let 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
}
}
)
} else {
LR.log(pluginName, `Gun-addressed IPFS-stored file could not be resolved on IPFS:\nGun: ${gunaddr}\nIPFS: ${ipfsaddr}`)
return null
}
}
/* ========================================================================= *\
|* === 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 = async resources => {
LR.log(pluginName, "adding to IPFS...")
LR.log(pluginName, "+-- number of resources:", resources.length)
let ipfsAddresses = new Map()
for(let res of resources) {
LR.log(pluginName, " +-- handling internal resource:", res)
let result = await ipfs.add(Ipfs.urlSource(res))
// 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 ipfsAddresses is in fact the absolute path (no domain, no scheme)
let absPath = res.replace(window.location.origin, '')
ipfsAddresses.set(absPath, "/ipfs/" + result.cid.toString())
LR.log(pluginName, "added to IPFS: " + absPath + " as " + ipfsAddresses.get(absPath))
}
return ipfsAddresses
}
/**
* 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) => {
LR.log(pluginName, 'checking IPFS content against a gateway...')
LR.log(pluginName, '+-- 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) {
LR.log(pluginName, '+-- verified: ' + ipfsaddr)
var pathIndex = updatedPaths.indexOf(ipfsaddr)
if (pathIndex > -1) {
updatedPaths.splice(pathIndex, 1)
}
if (updatedPaths.length === 0) {
LR.log(pluginName, '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) {
LR.log(pluginName, '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) => {
LR.log(pluginName, '+-- adding new IPFS addresses to Gun...')
// TODO BUG: this will probably fail, or cause failures down the line
// TODO BUG: we're setting a Map() object here; it's unclear how GunDB
// TODO BUG: will handle this
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
LR.log(pluginName, '+-- starting verification of updated Gun data...')
var updatedPaths = Array.from(ipfs_addresses.values())
for (const path of ipfs_addresses) {
LR.log(pluginName, ' +-- 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.get(updpath) == updaddr) {
// update worked!
gunUser.get(window.location.host).get(updpath).off()
LR.log(pluginName, '+-- update confirmed for:', updpath, '[' + updaddr + ']')
var pathIndex = updatedPaths.indexOf(updpath)
if (pathIndex > -1) {
updatedPaths.splice(pathIndex, 1)
}
if (updatedPaths.length === 0) {
LR.log(pluginName, '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 {
resource.forEach((res)=>{
if (typeof res !== 'string') {
throw new TypeError("Only accepts: string, Array of string, Response.")
}
})
}
} 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 === *|
\* ========================================================================= */
// and add ourselves to it
// with some additional metadata
return {
name: pluginName,
description: 'Decentralized resource fetching using Gun for address resolution and IPFS for content delivery.',
version: 'COMMIT_UNKNOWN',
fetch: getContentFromGunAndIPFS,
publish: publishContent
}
})
// done with not polluting the global namespace
})(LibResilientPluginConstructors)