beaker-plugin-dat
Version:
Dat-protocol plugin for the Beaker browser
286 lines (251 loc) • 8.82 kB
JavaScript
const { protocol } = require('electron')
const url = require('url')
const once = require('once')
const http = require('http')
const crypto = require('crypto')
const listenRandomPort = require('listen-random-port')
const log = require('loglevel')
const { ProtocolSetupError } = require('../lib/const')
const dat = require('../lib/dat')
const datDns = require('../lib/dat-dns')
const mime = require('../lib/mime')
const directoryListingPage = require('../lib/directory-listing-page')
const errorPage = require('../lib/error-page')
// constants
// =
// how long till we give up?
const REQUEST_TIMEOUT_MS = 5e3 // 5 seconds
// content security policies
const DAT_CSP = `
default-src 'self' dat:;
img-src 'self' data: dat:;
script-src 'self' 'unsafe-eval' 'unsafe-inline' dat:;
style-src 'self' 'unsafe-inline' dat:;
object-src 'none';
`.replace(/\n/g, '')
// globals
// =
var serverPort // port assigned to us
var requestNonce // used to limit access to the server from the outside
// exported api
// =
exports.scheme = 'dat'
exports.label = 'Dat Network'
exports.isStandardURL = true
exports.isInternal = false
exports.contextMenu = [{
label: 'View Site Files',
click: (win, props) => {
var urlp = url.parse(props.frameURL||props.pageURL)
if (urlp && urlp.hostname)
win.webContents.send('command', 'file:new-tab', 'beaker:archive/'+urlp.hostname+'/')
}
}]
exports.register = function () {
// generate a secret nonce
requestNonce = crypto.randomBytes(4).readUInt32LE(0)
// setup the network & db
dat.setup()
// setup the protocol handler
protocol.registerHttpProtocol('dat',
(request, cb) => {
// send requests to the protocol server
cb({
method: request.method,
url: 'http://localhost:'+serverPort+'/?url='+encodeURIComponent(request.url)+'&nonce='+requestNonce
})
}, err => {
if (err)
throw ProtocolSetupError(err, 'Failed to create protocol: dat')
}
)
// configure chromium's permissions for the protocol
protocol.registerServiceWorkerSchemes(['dat'])
// create the internal dat HTTP server
var server = http.createServer(datServer)
listenRandomPort(server, { host: '127.0.0.1' }, (err, port) => serverPort = port)
}
function datServer (req, res) {
var cb = once((code, status) => {
res.writeHead(code, status, {
'Content-Type': 'text/html',
'Content-Security-Policy': "default-src 'unsafe-inline';",
'Access-Control-Allow-Origin': '*'
})
res.end(errorPage(code + ' ' + status))
})
var queryParams = url.parse(req.url, true).query
var fileReadStream
var headersSent = false
// check the nonce
// (only want this process to access the server)
if (queryParams.nonce != requestNonce)
return cb(403, 'Forbidden')
// validate request
var urlp = url.parse(queryParams.url)
if (!urlp.host)
return cb(404, 'Archive Not Found')
if (req.method != 'GET')
return cb(405, 'Method Not Supported')
// stateful vars that may need cleanup
var timeout
function cleanup () {
if (timeout)
clearTimeout(timeout)
}
// track whether the request has been aborted by client
// if, after some async, we find `aborted == true`, then we just stop
var aborted = false
req.once('aborted', () => {
aborted = true
cleanup()
log.debug('[DAT] Request aborted by client')
})
// resolve the name
// (if it's a hostname, do a DNS lookup)
datDns.resolveName(urlp.host, (err, archiveKey) => {
if (aborted) return cleanup()
if (err)
return cb(404, 'No DNS record found for ' + urlp.host)
// start searching the network
var archive = dat.getArchive(archiveKey)
if (!archive) {
archive = dat.loadArchive(new Buffer(archiveKey, 'hex'))
dat.swarm(archiveKey)
}
// declare a redirect helper
var redirectToViewDat = once(hashOpt => {
hashOpt = hashOpt || ''
// the following code crashes the shit out of electron (https://github.com/electron/electron/issues/6492)
// res.writeHead(302, 'Found', { 'Location': 'beaker:archive/'+archiveKey+urlp.path })
// return res.end()
// use the html redirect instead, for now
res.writeHead(200, 'OK', {
'Content-Type': 'text/html',
'Content-Security-Policy': DAT_CSP,
'Access-Control-Allow-Origin': '*'
})
res.end('<meta http-equiv="refresh" content="0;URL=beaker:archive/'+archiveKey+urlp.path+hashOpt+'">')
return
})
// setup a timeout
timeout = setTimeout(() => {
if (aborted) return
// cleanup
aborted = true
log.debug('[DAT] Timed out searching for', archiveKey)
if (fileReadStream) {
fileReadStream.destroy()
fileReadStream = null
}
// respond
if (!urlp.path || urlp.path.endsWith('/') || urlp.path.endsWith('.html') ) {
// redirect to view-dat, to give a nice interface, if this looks like a page-request
redirectToViewDat('#timeout')
} else {
// error page
cb(408, 'Timed out')
}
}, REQUEST_TIMEOUT_MS)
archive.open(err => {
if (aborted) return cleanup()
if (err) {
log.debug('[DAT] Failed to open archive', archiveKey, err)
cleanup()
return cb(500, 'Failed')
}
// lookup entry
log.debug('[DAT] attempting to lookup', archiveKey)
var hasExactMatch = false // if there's ever an exact match, then dont look for near-matches
var filepath = decodeURIComponent(urlp.path)
if (!filepath || filepath == '/') filepath = '/index.html'
if (filepath.indexOf('?') !== -1) filepath = filepath.slice(0, filepath.indexOf('?')) // strip off any query params
const checkMatch = (entry, name) => {
// check exact match
if (name === filepath) {
hasExactMatch = true
return true
}
// check inexact matches
if (!hasExactMatch) {
// try appending .html
if (name === filepath + '.html')
return true
// try appending .htm
if (name === filepath + '.htm')
return true
}
}
dat.archiveCustomLookup(archive, checkMatch, entry => {
// still serving?
if (aborted)
return cleanup()
// not found
if (!entry) {
log.debug('[DAT] Entry not found:', urlp.path)
cleanup()
// if we're looking for a directory, render the file listing
if (!urlp.path || urlp.path.endsWith('/')) {
res.writeHead(200, 'OK', {
'Content-Type': 'text/html',
'Content-Security-Policy': DAT_CSP,
'Access-Control-Allow-Origin': '*'
})
return directoryListingPage(archive, urlp.path, html => res.end(html))
}
return cb(404, 'File Not Found')
}
// fetch the entry and stream the response
log.debug('[DAT] Entry found:', urlp.path)
fileReadStream = archive.createFileReadStream(entry)
fileReadStream
.pipe(mime.identifyStream(entry.name, mimeType => {
// cleanup the timeout now, as bytes have begun to stream
cleanup()
// send headers, now that we can identify the data
headersSent = true
var headers = {
'Content-Type': mimeType,
'Content-Security-Policy': DAT_CSP,
'Access-Control-Allow-Origin': '*'
}
if (entry.length)
headers['Content-Length'] = entry.length
res.writeHead(200, 'OK', headers)
}))
.pipe(res)
// handle empty files
fileReadStream.once('end', () => {
if (!headersSent) {
log.debug('[DAT] Served empty file')
res.writeHead(200, 'OK', {
'Content-Security-Policy': DAT_CSP,
'Access-Control-Allow-Origin': '*'
})
res.end('\n')
// TODO
// for some reason, sending an empty end is not closing the request
// this may be an issue in beaker's interpretation of the page-load ?
// but Im solving it here for now, with a '\n'
// -prf
}
})
// handle read-stream errors
fileReadStream.once('error', err => {
log.debug('[DAT] Error reading file', err)
if (!headersSent)
cb(500, 'Failed to read file')
})
// abort if the client aborts
req.once('aborted', () => {
if (fileReadStream) {
fileReadStream.destroy()
}
})
})
})
})
}
function removePrecedingSlash (str) {
return (str && str.charAt(0) == '/') ? str.slice(1) : str
}