bt-fetch
Version:
Interact with Bittorrent the same way you would websites via fetch()
620 lines (522 loc) • 17.3 kB
JavaScript
import WebTorrent from 'webtorrent'
import fs from 'fs-extra'
import path from 'path'
import sha1 from 'simple-sha1'
import ed from 'ed25519-supercop'
import derive from 'derive-key'
import bencode from 'bencode'
import busboy from 'busboy'
import { Readable } from 'stream'
import tmp from 'tmp-promise'
import crypto from 'crypto'
const DERIVE_NAMESPACE = 'bittorrent://'
const ERR_NOT_RESOLVE_ADDRESS = 'Could not resolve address'
const ERR_COULD_NOT_RESOLVE_TORRENT = 'Could not resolve torrent'
// 30 mins delay between reloading torrents
const DEFAULT_RELOAD_INTERVAL = 1000 * 60 * 30
// saves us from saving secret keys(saving secret keys even encrypted secret keys is something i want to avoid)
// with this function which was taken from the bittorrent-dht package
// we save only the signatures when we first publish a BEP46 torrent
function encodeSigData (msg) {
const ref = { seq: msg.seq, v: msg.v }
if (msg.salt) ref.salt = msg.salt
return bencode.encode(ref).slice(1, -1)
}
// setting up constants
const HASH_REGEX = /^[a-fA-F0-9]{40}$/
const ADDRESS_REGEX = /^[a-fA-F0-9]{64}$/
const DEFAULT_OPTS = {
folder: './',
timeout: 60000,
reloadInterval: DEFAULT_RELOAD_INTERVAL
}
export default class TorrentManager {
constructor (opts = {}) {
const finalOpts = { ...DEFAULT_OPTS, ...opts }
this.timeout = finalOpts.timeout
this.reloadInterval = finalOpts.reloadInterval
this.folder = path.resolve(finalOpts.folder)
this.dataFolder = path.join(this.folder, 'data')
this.metadataFolder = path.join(this.folder, 'metadata')
this.seedKeyFile = path.join(this.folder, 'seed.key')
fs.ensureDirSync(this.folder)
fs.ensureDirSync(this.dataFolder)
fs.ensureDirSync(this.metadataFolder)
if (fs.existsSync(this.seedKeyFile)) {
this.seedKey = fs.readFileSync(this.seedKeyFile)
} else {
this.seedKey = crypto.randomBytes(32)
fs.writeFileSync(this.seedKeyFile, this.seedKey)
}
this.inProgressLoad = new Map()
this.byInfohash = new Map()
this.byPublicKey = new Map()
function verify (signature, message, publicKey) {
return ed.verify(signature, Buffer.from(message), publicKey)
}
this.webtorrent = new WebTorrent({ dht: { verify }, ...finalOpts })
this._readyToGo = true
this.reloadTimer = setInterval(() => this.reloadAll(), this.reloadInterval)
}
_trackTorrent (torrent) {
const { infoHash, publicKey, name, files, path: torrentPath } = torrent
const parentPath = path.join(torrentPath, name)
files.forEach((file) => {
file.relativePath = file.path.slice(parentPath.length).replace(/\\/, '/')
})
const infoHashURL = infoHash.toString('hex')
if (this.byInfohash.has(infoHashURL)) throw new Error('Already tracking ' + infoHashURL)
this.byInfohash.set(infoHashURL, torrent)
torrent.on('close', () => {
this.byInfohash.delete(infoHashURL)
})
if (!publicKey) return
const publicKeyURL = publicKey.toString('hex')
if (this.byInfohash.has(publicKeyURL)) throw new Error('Already tracking ' + publicKeyURL)
this.byPublicKey.set(publicKeyURL, torrent)
torrent.on('close', () => {
this.byPublicKey.delete(publicKeyURL)
})
}
async resolveTorrent (hostname) {
if (this.inProgressLoad.has(hostname)) {
return this.inProgressLoad.get(hostname)
}
const loader = this._resolveTorrent(hostname)
this.inProgressLoad.set(hostname, loader)
try {
const torrent = await loader
return torrent
} finally {
this.inProgressLoad.delete(hostname)
}
}
async _resolveTorrent (hostname) {
const isInfohash = hostname.length === 40 && HASH_REGEX.test(hostname)
const isPublicKey = hostname.length === 64 && ADDRESS_REGEX.test(hostname)
if (isInfohash) {
if (this.byInfohash.has(hostname)) {
return this.byInfohash.get(hostname)
}
return this.loadFromInfoHash(hostname)
} else if (isPublicKey) {
if (this.byPublicKey.has(hostname)) {
return this.byPublicKey.get(hostname)
}
return this.loadFromPublicKey(hostname)
} else throw new Error('Unknown hostname type')
}
async loadFromInfoHash (infoHash) {
const torrentFile = await this.loadTorrentFile(infoHash)
const torrentId = torrentFile || {
infoHash,
so: '-1'
}
const folderPath = path.join(this.dataFolder, infoHash)
const torrent = await this.loadTorrent(torrentId, folderPath)
this._trackTorrent(torrent)
return torrent
}
async loadFromPublicKey (publicKey) {
const folderPath = path.join(this.dataFolder, publicKey)
const existingRecord = await this.loadRecord(publicKey)
// Load latest from DHT, and load the torrent
try {
let { infoHash, record, sequence } = await this.resolvePublicKey(publicKey)
let torrentFile = null
if (existingRecord && existingRecord?.seq >= sequence) {
infoHash = existingRecord.v.ih.toString('hex')
torrentFile = await this.loadTorrentFile(publicKey)
record = existingRecord
sequence = existingRecord.seq
} else {
await this.saveRecord(publicKey, record)
}
const torrentId = torrentFile || {
infoHash,
so: '-1'
}
const torrent = await this.loadTorrent(torrentId, folderPath)
torrent.publicKey = publicKey
torrent.record = record
torrent.sequence = sequence
this._trackTorrent(torrent)
await this.saveTorrentFile(torrent)
return torrent
} catch (cause) {
// TODO: Other messages that mean we could not resolve the address?
if (!cause.message.includes(ERR_NOT_RESOLVE_ADDRESS)) throw cause
// If it fails, try loading record from cache
// Use saved torrent file (underpublickey) to load the torrent
// TODO: Check error type?
if (!existingRecord) throw new Error(ERR_COULD_NOT_RESOLVE_TORRENT, { cause })
const torrentFile = await this.loadTorrentFile(publicKey)
const torrent = await this.loadTorrent(torrentFile, folderPath)
torrent.record = existingRecord
torrent.publicKey = publicKey
this._trackTorrent(torrent)
return torrent
}
}
async loadTorrent (torrentId, folderPath) {
await fs.ensureDir(folderPath)
const options = {
path: folderPath,
addUID: false
}
const torrent = await withTimeout(
new Promise((resolve, reject) => {
this.webtorrent.add(torrentId, options, torrent => {
resolve(torrent)
})
}),
this.timeout,
new Error('Timeout: torrent took too long to load')
)
return torrent
}
async deleteTorrent (hostname) {
const torrent = await this.resolveTorrent(hostname)
return new Promise((resolve, reject) => {
torrent.destroy({ destroyStore: true }, error => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
async reloadAll () {
const all = [...this.byPublicKey.values()]
await Promise.all(all.map((torrent) => this.reloadTorrent(torrent)))
}
async reloadTorrent (torrent) {
const { publicKey } = torrent
const loader = this._reloadTorrent(torrent)
// If the frontend sends a request while we're reloading, make it wait
this.inProgressLoad.set(publicKey, loader)
try {
await loader
} finally {
this.inProgressLoad.delete(publicKey)
}
}
async _reloadTorrent (torrent) {
// Do a DHT get to see if there's a new version (compare sequence)
const { publicKey } = torrent
try {
const { sequence } = await this.resolvePublicKey(publicKey)
if (sequence > torrent.sequence) {
// If there's a new version destroy the torrent and load it again
await new Promise((resolve, reject) => {
torrent.destroy((err) => {
if (err) reject(err)
else resolve()
})
})
return this.loadFromPublicKey(publicKey)
} else {
// If there isn't do a DHT put with the existing record
await this.dhtPut(torrent.record)
return torrent
}
} catch {
// ToDO: Handle errors?
return torrent
}
}
async republishPublicKey (publicKey, secretKey, opts = {}) {
const { name } = opts
if (!name) {
throw new Error('Must specify torrent name for publishing')
}
const folderPath = path.join(this.dataFolder, publicKey, name)
// TODO: Support "info" field?
const finalOpts = {
...opts,
addUID: false
}
const tmpTorrent = await new Promise((resolve, reject) => {
this.webtorrent.seed(folderPath, finalOpts, torrent => {
resolve(torrent)
})
})
tmpTorrent.publicKey = publicKey
const { infoHash } = tmpTorrent
await this.saveTorrentFile(tmpTorrent)
await new Promise((resolve, reject) => {
tmpTorrent.destroy((err) => {
if (err) reject(err)
else resolve()
})
})
// Try to resolve existing sequence?
let sequence = 0
try {
const record = await this.dhtGet(publicKey)
if (record?.seq) {
sequence = record?.seq
}
} catch (e) {
// Whatever?
if (!e.message.includes(ERR_NOT_RESOLVE_ADDRESS)) {
console.error(e)
}
}
// Generate record and publish
await this.dhtPublish(publicKey, secretKey, infoHash, sequence)
return this.loadFromPublicKey(publicKey)
}
async stopSeedingPublicKey (publicKey) {
if (!this.byPublicKey.has(publicKey)) {
return false
}
await new Promise((resolve, reject) => {
const torrent = this.byPublicKey.get(publicKey)
torrent.destroy((err) => {
if (err) reject(err)
else resolve(true)
})
})
}
async publishPublicKey (publicKey, secretKey, headers, data, pathname = '/', name = 'bt-fetch torrent') {
if (this.byPublicKey.has(publicKey)) {
const torrent = this.byPublicKey.get(publicKey)
name = torrent.name
} else {
try {
// Try loading existing state so we can fetch the name out
const torrent = await this.resolveTorrent(publicKey)
name = torrent.name
} catch (e) {
// Whatever?
if (!e.message.includes(ERR_NOT_RESOLVE_ADDRESS) && !e.message.includes(ERR_COULD_NOT_RESOLVE_TORRENT)) {
console.error(e.stack)
}
}
}
await this.stopSeedingPublicKey(publicKey)
const folderPath = path.join(this.dataFolder, publicKey, name)
const savePath = path.join(folderPath, pathname)
await fs.ensureDir(savePath)
const {
comment,
createdBy = 'bt-fetch',
creationDate
} = await this.handleFormData(savePath, headers, data)
const finalOpts = {
name,
comment,
createdBy,
creationDate
}
return this.republishPublicKey(publicKey, secretKey, finalOpts)
}
async publishHash (headers, data, pathname = '/', title = 'bt-fetch torrent') {
const { path: tmpPath, cleanup } = await tmp.dir({ unsafeCleanup: true })
try {
await fs.ensureDir(tmpPath)
const savePath = path.join(tmpPath, pathname)
const {
name = title,
comment,
createdBy = 'bt-fetch',
creationDate
} = await this.handleFormData(savePath, headers, data)
// TODO: Support "info" field?
const finalOpts = {
name,
comment,
createdBy,
creationDate,
addUID: false
}
const tmpTorrent = await new Promise((resolve, reject) => {
this.webtorrent.seed(tmpPath, finalOpts, torrent => {
resolve(torrent)
})
})
const { infoHash } = tmpTorrent
await this.saveTorrentFile(tmpTorrent)
await new Promise((resolve, reject) => {
tmpTorrent.destroy((err) => {
if (err) reject(err)
else resolve()
})
})
const folderPath = path.join(this.dataFolder, infoHash, name)
await fs.move(tmpPath, folderPath)
return this.loadFromInfoHash(infoHash)
} finally {
cleanup()
}
}
async dhtGet (publicKey) {
try {
const record = await new Promise((resolve, reject) => {
sha1(Buffer.from(publicKey, 'hex'), (targetID) => {
this.webtorrent.dht.get(targetID, (err, res) => {
if (err) {
reject(err)
} else if (res) {
resolve(res)
} else if (!res) {
reject(new Error(ERR_NOT_RESOLVE_ADDRESS))
}
})
})
})
return record
} catch (e) {
if (!e.message.includes(ERR_NOT_RESOLVE_ADDRESS)) {
console.error('Could not load DHT record', e.stack)
}
const record = await this.loadRecord(publicKey)
if (record) return record
throw e
}
}
async dhtPut (data) {
return new Promise((resolve, reject) => {
this.webtorrent.dht.put(data, (err, hash, nodes) => {
if (err) {
reject(err)
} else {
resolve(hash.toString('hex'))
}
})
})
}
async dhtPublish (publicKey, secretKey, infoHash, seq = 0) {
const publicKeyBuff = Buffer.from(publicKey, 'hex')
const secretKeyBuff = Buffer.from(secretKey, 'hex')
const ih = Buffer.from(infoHash, 'hex')
const v = { ih }
const toSign = encodeSigData({ seq, v })
const sig = ed.sign(toSign, publicKeyBuff, secretKeyBuff)
const record = { k: publicKeyBuff, v, seq, sig }
await this.saveRecord(publicKey, record)
return this.dhtPut(record)
}
async loadRecord (publicKey) {
const fileLocation = path.join(this.folder, 'metadata', `${publicKey}.dht_record`)
const exists = await fs.pathExists(fileLocation)
if (!exists) return null
const buffer = await fs.readFile(fileLocation)
return bencode.decode(buffer)
}
async saveRecord (publicKey, record) {
const fileLocation = path.join(this.folder, 'metadata', `${publicKey}.dht_record`)
const buffer = bencode.encode(record)
await fs.writeFile(fileLocation, buffer)
}
async saveTorrentFile (torrent) {
const { publicKey, infoHash, torrentFile } = torrent
const hostname = publicKey || infoHash
const fileLocation = path.join(this.folder, 'metadata', `${hostname}.torrent`)
await fs.writeFile(fileLocation, torrentFile)
}
async loadTorrentFile (hostname) {
const fileLocation = path.join(this.folder, 'metadata', `${hostname}.torrent`)
const exists = await fs.pathExists(fileLocation)
if (!exists) return null
return fs.readFile(fileLocation)
}
// resolve public key address to an infohash
async resolvePublicKey (publicKey) {
const record = await this.dhtGet(publicKey)
const { seq, v } = record
const { ih } = v
const infoHash = ih.toString('hex')
const sequence = seq
if (!HASH_REGEX.test(infoHash)) throw new Error('Resolved public key infoHash invalid')
if (!Number.isInteger(sequence)) throw new Error('Resolved public key sequence number invalid')
return {
infoHash,
sequence,
record
}
}
handleFormData (folderPath, headers, data) {
const bb = busboy({ headers })
const additionalInfo = {}
return new Promise((resolve, reject) => {
function handleRemoval () {
bb.off('file', handleFiles)
bb.off('error', handleErrors)
bb.off('finish', handleFinish)
}
function handleFiles (name, file, info) {
const { filename } = info
const finalLocation = path.join(folderPath, filename)
const writeStream = fs.createWriteStream(finalLocation)
Readable.from(file).pipe(writeStream)
}
function handleErrors (error) {
handleRemoval()
reject(error)
}
function handleFinish () {
handleRemoval()
resolve(additionalInfo)
}
function handleField (name, val) {
additionalInfo[name] = val
}
bb.on('file', handleFiles)
bb.on('field', handleField)
bb.once('error', handleErrors)
bb.once('close', handleFinish)
Readable.from(data).pipe(bb)
})
}
// create a keypair
createKeypair (petname = null) {
let seed = null
if (petname) {
seed = derive(DERIVE_NAMESPACE, this.seedKey, petname)
} else {
seed = ed.createSeed()
}
const {
publicKey,
secretKey
} = ed.createKeyPair(seed)
return {
publicKey: publicKey.toString('hex'),
secretKey: secretKey.toString('hex')
}
}
async destroy () {
clearInterval(this.reloadTimer)
return new Promise((resolve, reject) => {
this.webtorrent.destroy(error => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
}
async function withTimeout (promise, timeout, data, shouldResolve = false) {
let timeoutId = null
function clear () {
clearTimeout(timeoutId)
}
promise.then(clear, clear)
return Promise.race([
promise,
new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
if (shouldResolve) {
resolve(data)
} else {
reject(data)
}
}, timeout)
})
])
}