UNPKG

beaker-plugin-dat

Version:

Dat-protocol plugin for the Beaker browser

800 lines (692 loc) 23.7 kB
const { app, shell } = require('electron') const concat = require('concat-stream') const from = require('from2') const fromString = require('from2-string') const emitStream = require('emit-stream') const EventEmitter = require('events') const pump = require('pump') const multicb = require('multicb') const log = require('loglevel') const trackArchiveEvents = require('./track-archive-events') const { throttle } = require('./functions') const newSiteTemplate = require('./new-site-template') // db modules const hyperdrive = require('hyperdrive') const level = require('level') const subleveldown = require('subleveldown') // network modules const hyperdriveArchiveSwarm = require('hyperdrive-archive-swarm') const hyperImport = require('hyperdrive-import-files') // const electronWebrtc = require('electron-webrtc') const datDns = require('./dat-dns') // file modules const path = require('path') const fs = require('fs') const raf = require('random-access-file') const mkdirp = require('mkdirp') const getFolderSize = require('get-folder-size') // constants // = const { MANIFEST_FILENAME } = require('./const') // where are the given archive's files kept const ARCHIVE_FILEPATH = archiveOrKey => path.join(dbPath, 'Archives', bufToStr(archiveOrKey.key || archiveOrKey)) // globals // = var dbPath // path to the hyperdrive folder var db // level instance var archiveMetaDb // archive metadata sublevel var archiveUserSettingsDb // archive user-settings sublevel var globalSettingsDb // global settings sublevel var drive // hyperdrive instance // var wrtc // webrtc instance DISABLED until #1 is fixed var archives = {} // key -> archive var swarms = {} // key -> swarm var savedArchives = new Set() // set of saved archives var archivesEvents = new EventEmitter() // exported API // = const setup = exports.setup = function () { // open databases dbPath = path.join(app.getPath('userData'), 'Hyperdrive') mkdirp.sync(path.join(dbPath, 'Archives')) // make sure the folders exist db = level(dbPath) archiveMetaDb = subleveldown(db, 'archive-meta', { valueEncoding: 'json' }) archiveUserSettingsDb = subleveldown(db, 'archive-user-settings', { valueEncoding: 'json' }) globalSettingsDb = subleveldown(db, 'global-settings', { valueEncoding: 'json' }) drive = hyperdrive(db) log.debug('[DAT] Database location:', dbPath) // DISABLED until issue #1 is fixed // create webrtc // wrtc = electronWebrtc() // wrtc.on('error', err => log.warn('[DAT] WebRTC:', err)) // load all saved archives and start swarming them archiveUserSettingsDb.createReadStream().on('data', entry => { if (entry.value.isSaved) { savedArchives.add(entry.key) configureArchive(entry.key, entry.value) } }) } // load archive and set the swarming behaviors const configureArchive = function (key, config) { // load and swarm var archive = loadArchive(new Buffer(key, 'hex'), { sparse: !config.isServing, // only download on-demand (sparse mode) if the archive isnt actively syncing (serving) live: true }) swarm(key, { upload: config.isServing || !archive.owner, // only upload if the user wants to serve, or isnt the owner download: true // always download updates }) } const createNewArchive = exports.createNewArchive = function (opts) { opts = opts || {} var title = (opts.title && typeof opts.title == 'string') ? opts.title : '' var description = (opts.description && typeof opts.description == 'string') ? opts.description : '' return new Promise((resolve, reject) => { // create the archive var archive = loadArchive(null, { live: true }) var key = archive.key.toString('hex') setArchiveUserSettings(key, { isSaved: true, isServing: false }) // add any requested files var done = multicb() if (opts.importFiles) { let importFiles = Array.isArray(opts.importFiles) ? opts.importFiles : [opts.importFiles] importFiles.forEach(importFile => writeArchiveFileFromPath(key, { src: importFile, dst: '/' })) } if (opts.title || opts.description) { writeArchiveFile(archive, MANIFEST_FILENAME, JSON.stringify({ title, description }), done()) } if (opts.useNewSiteTemplate) { writeArchiveFile(archive, 'index.html', newSiteTemplate.indexHTML({ key }), done()) writeArchiveFile(archive, 'favicon.png', newSiteTemplate.faviconPNG(), done()) } done(() => resolve(key)) }) } const forkArchive = exports.forkArchive = function (oldArchiveKey, opts) { opts = opts || {} var title = (opts.title && typeof opts.title == 'string') ? opts.title : '' var description = (opts.description && typeof opts.description == 'string') ? opts.description : '' return new Promise((resolve, reject) => { // get the target archive var oldArchive = getArchive(oldArchiveKey) if (!oldArchive) { return reject(new Error('Invalid archive key')) } // create the new archive createNewArchive({ title, description }).then(newArchiveKey => { // list the old archive's files var newArchive = getArchive(newArchiveKey) oldArchive.list((err, entries) => { if (err) { return reject(err) } // TEMPORARY // remove duplicates // this is only needed until hyperdrive fixes its .list() // see https://github.com/mafintosh/hyperdrive/pull/99 // -prf var entriesDeDuped = {} entries.forEach(entry => entriesDeDuped[entry.name] = entry) entries = Object.keys(entriesDeDuped).map(name => entriesDeDuped[name]) // copy over files next() function next (err) { if (err) { return reject(err) } // get next var entry = entries.shift() if (!entry) { return finish() } // skip non-files, undownloaded files, and the old manifest if (entry.type !== 'file' || !oldArchive.isEntryDownloaded(entry) || entry.name === MANIFEST_FILENAME) { return next() } // copy the fine pump( oldArchive.createFileReadStream(entry), newArchive.createFileWriteStream({ name: entry.name, mtime: entry.mtime, ctime: entry.ctime }), next ) } function finish () { // save the new archive setArchiveUserSettings(newArchiveKey, { isSaved: true }) .catch(() => false) // squash failures .then(() => resolve(newArchiveKey)) } }) }).catch(reject) }) } const loadArchive = exports.loadArchive = function (key, opts) { opts = opts || {} var sparse = (opts.sparse === false) ? false : true // by default, only download files when they're requested // validate key if (key !== null && (!Buffer.isBuffer(key) || key.length != 32)) return var archive = drive.createArchive(key, { live: opts.live, // optional! only set this if you know what it should be sparse: sparse, file: name => raf(path.join(ARCHIVE_FILEPATH(archive), name)) }) mkdirp.sync(ARCHIVE_FILEPATH(archive)) // ensure the folder exists cacheArchive(archive) archive.pullLatestArchiveMeta = throttle(() => pullLatestArchiveMeta(archive), 1e3) trackArchiveEvents(archivesEvents, archive) // start tracking the archive's events // if in sparse-mode, prioritize the entire metadata feed, but leave content to be on-demand if (sparse) { archive.metadata.prioritize({priority: 0, start: 0, end: Infinity}) } return archive } const cacheArchive = exports.cacheArchive = function (archive) { archives[archive.key.toString('hex')] = archive } const getArchive = exports.getArchive = function (key) { return archives[bufToStr(key)] } const getArchiveInfo = exports.getArchiveInfo = function (name, opts) { return new Promise((resolve, reject) => { datDns.resolveName(name, (err, key) => { if (err) return reject(new Error(err.code || err)) // get the archive var archive = getArchive(key) if (!archive) { if (opts && opts.loadIfMissing) { archive = loadArchive(new Buffer(key, 'hex')) swarm(key) } else { return reject(new Error('Invalid archive key')) } } // fetch archive data var done = multicb({ pluck: 1, spread: true }) getArchiveMeta(key, done()) archive.list(done()) readReadme(archive, done()) done((err, meta, entries, readme) => { if (err) return reject(err) // attach additional data meta.key = key meta.entries = entries meta.contentBitfield = archive.content.bitfield.buffer meta.readme = readme meta.isApp = entries && !!entries.find(e => e.name == 'index.html') resolve(meta) }) }) }) } const getArchiveStats = exports.getArchiveStats = function (key) { return new Promise((resolve, reject) => { // TODO replace this with hyperdrive-stats // TODO at time of writing, this will count overwritten files multiple times, because list() hasnt been fixed yet // initialize the stats structure var stats = { bytesTotal: 0, blocksProgress: 0, blocksTotal: 0, filesTotal: 0 } // fetch archive var archive = getArchive(key) if (!archive) { return reject(new Error('Invalid archive key')) } // fetch the archive entries archive.list((err, entries) => { if (err) { return reject(err) } // tally the current state entries.forEach(entry => { stats.bytesTotal += entry.length stats.blocksProgress += archive.countDownloadedBlocks(entry) stats.blocksTotal += entry.blocks stats.filesTotal++ }) resolve(stats) }) }) } const getSavedArchives = exports.getSavedArchives = function () { return new Promise((resolve, reject) => { var done = multicb({ pluck: 1 }) for (let key of savedArchives) getArchiveMeta(key, done()) done((err, archives) => { if (err) reject(err) else resolve(archives) }) }) } const setArchiveUserSettings = exports.setArchiveUserSettings = function (key, value) { return new Promise((resolve, reject) => { // db result handler var cb = err => { if (err) reject(err) else resolve() } // massage data var config = { isSaved: !!value.isSaved, isServing: !!value.isServing } // add/update if (config.isSaved) savedArchives.add(key) else savedArchives.delete(key) archiveUserSettingsDb.put(key, config, cb) // update the swarm/download behaviors var archive = getArchive(key) swarm(key).upload = config.isServing || (config.isSaved && !archive.owner) // upload if the user wants to serve, or has saved and isnt the owner if (config.isServing) { archive.content.prioritize({start: 0, end: Infinity}) // download content automatically } else { archive.content.unprioritize({start: 0, end: Infinity}) // download content on demand } }) } const updateArchiveManifest = exports.updateArchiveManifest = function (key, updates) { return new Promise((resolve, reject) => { // fetch the current manifest var archive = getOrLoadArchive(key) readManifest(archive, (err, manifest) => { // update values manifest = manifest || {} Object.assign(manifest, updates) // write to archive writeArchiveFile(archive, MANIFEST_FILENAME, JSON.stringify(manifest), err => { if (err) reject(err) else resolve() }) }) }) } const writeArchiveFileFromPath = exports.writeArchiveFileFromPath = function (key, opts) { return new Promise((resolve, reject) => { if (!opts || typeof opts != 'object' || typeof opts.src != 'string' || typeof opts.dst != 'string') return reject(new Error('Must provide .src and .dst filepaths')) // open the archive and ensure we can write var archive = getOrLoadArchive(key) archive.open(() => { if (!archive.owner) return reject(new Error('Cannot write: not the archive owner')) let { src, dst } = opts if (!dst) dst = '/' // update the dst if it's a directory try { var stat = fs.statSync(src) if (stat.isDirectory()) { // put at a subpath, so that the folder's contents dont get imported into the target dst = path.join(dst, path.basename(src)) } } catch (e) { return reject(new Error('File not found')) } // read the file or file-tree into the archive log.debug('[DAT] Writing file(s) from path:', src, 'to', dst) hyperImport(archive, src, { basePath: dst, live: false, resume: true, ignore: ['.dat', '**/.dat', '.git', '**/.git'] }, err => { if (err) reject(err) else resolve(err) }) }) }) } // put the archive into the network, for upload and download // (this is kind of like saying, "go live") const swarm = exports.swarm = function (key, opts) { var [keyBuf, keyStr] = bufAndStr(key) opts = Object.assign({ upload: false, download: true, /* wtrc */}, opts) // fetch if (keyStr in swarms) return swarms[keyStr] // create log.debug('[DAT] Swarming archive', keyStr) var archive = getArchive(key) var s = hyperdriveArchiveSwarm(archive, opts) swarms[keyStr] = s archivesEvents.emit('update-archive', { key: keyStr, isSharing: true }) // hook up events if (s.node) s.node.on('peer', peer => log.debug('[DAT] Connection', peer.id, 'from discovery-swarm')) else log.warn('Swarm .node missing') // if (s.browser) s.browser.on('peer', peer => log.debug('[DAT] Connection', peer.remoteAddress+':'+peer.remotePort, 'from webrtc')) // else log.warn('Swarm .browser missing') archive.open(err => { if (err) return log.warn('Error opening archive for swarming', keyStr, err) if (archive.metadata) { archive.metadata.on('download-finished', () => { log.debug('[DAT] Metadata download finished', keyStr) archivesEvents.emit('update-listing', { key: keyStr }) archive.pullLatestArchiveMeta() }) } }) return s } // take the archive out of the network const unswarm = exports.unswarm = function (key) { var [keyBuf, keyStr] = bufAndStr(key) // fetch var s = swarms[keyStr] if (!s || s.isClosing) return s.isClosing = true s.close(() => { log.debug('[DAT] Stopped swarming archive', keyStr) delete swarms[keyStr] archivesEvents.emit('update-archive', { key: keyStr, isSharing: false }) }) // TODO unregister ALL events that were registered in swarm() !! } // prioritize an entry for download const downloadArchiveEntry = exports.downloadArchiveEntry = function (key, name) { return new Promise((resolve, reject) => { // get the archive var archive = getArchive(key) if (!archive) return reject(new Error('Invalid archive key')) // lookup the entry archive.lookup(name, (err, entry) => { if (err || !entry) return reject(err) if (entry.type != 'file') return reject(new Error('Entry must be a file')) // download the entry archive.content.prioritize({ start: entry.content.blockOffset, end: entry.content.blockOffset + entry.blocks, priority: 3, linear: true }) archive.download(entry, err => { if (err) return reject(err) resolve() }) }) }) } // helper to run custom lookup rules // - checkFn is called with (entry). if it returns true, then `entry` is made the current match const archiveCustomLookup = exports.archiveCustomLookup = function (archive, checkFn, cb) { var entries = archive.list({live: false}) var entry = null entries.on('data', function (e) { if (checkFn(e, normalizedEntryName(e))) entry = e }) entries.on('error', lookupDone) entries.on('close', lookupDone) entries.on('end', lookupDone) function lookupDone () { cb(entry) } } const normalizedEntryName = exports.normalizedEntryName = function (entry) { var name = ('' + (entry.name || '')) return (name.startsWith('/')) ? name : ('/' + name) } // helper to write file data to an archive const writeArchiveFile = exports.writeArchiveFile = function (archive, name, data, cb) { pump( typeof data === 'string' ? fromString(data) : fromBuffer(data), archive.createFileWriteStream({ name, mtime: Date.now() }), cb ) } // helper to pull file data from an archive const readArchiveFile = exports.readArchiveFile = function (archive, name, opts, cb) { if (typeof opts === 'function') { cb = opts opts = {} } if (typeof opts === 'string') { opts = { encoding: opts } } opts.encoding = toValidEncoding(opts.encoding) name = normalizedEntryName({ name }) archiveCustomLookup( archive, (entry, entryName) => entryName === name, entry => { if (!entry || entry.type !== 'file') { return cb({ notFound: true }) } var rs = archive.createFileReadStream(entry) rs.pipe(concat(data => { if (opts.encoding !== 'binary') { data = data.toString(opts.encoding) } cb(null, data) })) rs.on('error', e => cb(e)) } ) } const readArchiveDirectory = exports.readArchiveDirectory = function (archive, dstPath, cb) { var dstPathParts = dstPath.split('/') if (dstPathParts.length > 1 && !dstPathParts[dstPathParts.length - 1]) dstPathParts.pop() // drop the last empty '' // start a list stream var s = archive.list({live: false}) var entries = {} s.on('data', function (e) { // check if the entry is a child of the given path var entryPath = normalizedEntryName(e) var entryPathParts = entryPath.split('/') if (entryPathParts.length > 1 && !entryPathParts[entryPathParts.length - 1]) entryPathParts.pop() // drop the last empty '' if (entryPathParts.length !== dstPathParts.length && isPathChild(dstPathParts, entryPathParts)) { // use the subname var name = entryPathParts[dstPathParts.length] // child should have exactly 1 more item than the containing path var isImmediateChild = (entryPathParts.length === dstPathParts.length + 1) if (isImmediateChild) { entries[name] = e } else { // not an immediate child - add the directory if DNE if (!entries[name]) { entries[name] = { type: 'directory', name: path.join(dstPath, name) } } } } }) s.on('error', lookupDone) s.on('close', lookupDone) s.on('end', lookupDone) function lookupDone () { cb(null, entries) } } const archivesEventStream = exports.archivesEventStream = function () { return emitStream(archivesEvents) } const openInExplorer = exports.openInExplorer = function (key) { var folderpath = ARCHIVE_FILEPATH(key) log.debug('[DAT] Opening in explorer:', folderpath) shell.openExternal('file://'+folderpath) } const getGlobalSetting = exports.getGlobalSetting = function (key) { return new Promise((resolve, reject) => { globalSettingsDb.get(key, (err, value) => { if (err) reject(err) else resolve(value) }) }) } const setGlobalSetting = exports.setGlobalSetting = function (key, value) { return new Promise((resolve, reject) => { globalSettingsDb.put(key, value, (err, value) => { if (err) reject(err) else resolve(value) }) }) } // internal methods // = function getOrLoadArchive (key) { return getArchive(key) || loadArchive(new Buffer(key, 'hex')) } const getArchiveMeta = function (key, cb) { key = bufToStr(key) // open archive var archive = getOrLoadArchive(key) archive.open(() => { // pull data from meta db archiveMetaDb.get(key, (err, meta) => { meta = meta || {} // TEMPORARY fallback to legacy .name if no .title if (meta.name || !meta.title) meta.title = meta.name // pull user settings from saved db archiveUserSettingsDb.get(key, (err, userSettings) => { userSettings = userSettings || {} // give sane defaults, and add live data userSettings = Object.assign({ isSaved: false, isServing: false }, userSettings) meta = Object.assign({ key, title: 'Untitled', author: false, mtime: 0, size: 0, isOwner: !!archive.owner, isServing: ((key in swarms) && swarms[key].uploading), peers: archive.metadata.peers.length, userSettings }, meta) cb(null, meta) }) }) }) } // read metadata for the archive, and store it in the meta db const pullLatestArchiveMeta = function (archive) { var key = archive.key.toString('hex') var done = multicb({ pluck: 1, spread: true }) // open() just in case (we need .blocks) archive.open(() => { // read the archive metafiles readManifest(archive, done()) // calculate the size on disk var sizeCb = done() getFolderSize(ARCHIVE_FILEPATH(archive), (err, size) => { sizeCb(null, size) }) done((err, manifest, size) => { manifest = manifest || {} var { title, description, author, homepage_url } = manifest var mtime = Date.now() // use our local update time size = size || 0 // write the record var update = { title, description, author, homepage_url, mtime, size } log.debug('[DAT] Writing meta', update) archiveMetaDb.put(key, update, err => { if (err) log.debug('[DAT] Error while writing archive meta', key, err) // emit event update.key = key archivesEvents.emit('update-archive', update) }) }) }) } function readManifest (archive, cb) { readArchiveFile(archive, MANIFEST_FILENAME, (err, data) => { if (data) return done(data) // TEMPORARY try legacy (remove in, like, a year. maybe less.) readArchiveFile(archive, 'manifest.json', (err, data) => { if (data) return done(data) // no manifest cb() }) }) function done (data) { // parse manifest try { var manifest = JSON.parse(data.toString()) if (manifest.name || !manifest.title) manifest.title = manifest.name // TEMPORARY legacy fix cb(null, manifest) } catch (e) { cb() } } } function readReadme (archive, cb) { readArchiveFile(archive, 'README.md', (err, data) => cb(null, data)) // squash the error } // get buffer and string version of value function bufAndStr (v) { if (Buffer.isBuffer(v)) return [v, v.toString('hex')] return [new Buffer(v, 'hex'), v] } // convert to string, if currently a buffer function bufToStr (v) { if (Buffer.isBuffer(v)) return v.toString('hex') return v } // convert a buffer into a readable string function fromBuffer (buf) { var i = 0 return from(function (size, next) { if (i >= buf.length) return next(null, null) var chunk = buf.slice(i, i+size) i += size next(null, chunk) }) } // helper to convert an encoding to something acceptable function toValidEncoding (str) { if (!str) return 'utf8' if (!['utf8', 'utf-8', 'hex', 'base64', 'binary'].includes(str)) return 'binary' return str } // `pathParts` and `childParts` should be arrays (`str.split('/')`) function isPathChild (pathParts, childParts) { // all path parts should be contained in the child parts for (var i = 0; i < pathParts.length; i++) { if (pathParts[i] !== childParts[i]) return false } return true }