UNPKG

webseeded-torrent-generator

Version:

Generate webseeded torrent files from urls to files.

382 lines (339 loc) 11.8 kB
'use strict'; var _ = require('lodash'); var q = require('q'); var qsequence = require('../util/qsequence'); var bencode = require('bencode'); var assert = require('assert'); var crypto = require('crypto'); var HttpHasher = require('./http-hasher'); var HttpFile = require('./http-fs'); var BunyanPromiseLogger = require('bunyan-promise'); var promiseLog = new BunyanPromiseLogger({ name: 'torrent' }); // http://torrentfreak.com/how-to-make-the-best-torrents-081121/ // recommends between 1200 and 2200 pieces // lets use a nice round number in the middle (2048) var MAX_SUGGESTED_NUM_PIECES = 0x800; // 256kB is the smallest allowable piece size var MIN_PIECE_SIZE = 0x40000; // regardless of size, piece size must be 4 ^ x var PIECE_SIZE_EXPONENT_MULTIPLE = 4; // there is an absolute max piece size of 32 Mb enforced by utorrent var MAX_PIECE_SIZE = 0x2000000; var DEFAULT_ANNOUNCE = 'udp://tracker.publicbt.com:80/announce'; var DEFAULT_ANNOUNCE_LIST = [ ['udp://tracker.publicbt.com:80/announce'], ['udp://tracker.openbittorrent.com:80/announce'] ]; var memoizeAll = function (obj) { var funcs = _.functions(obj); _.each(funcs, function (f) { obj[f] = _.memoize(obj[f]); }); return obj; }; function pemToDer(pem) { if (typeof pem !== 'string') { pem = pem.toString(); } var m; if ((m = pem.match(/^-----BEGIN CERTIFICATE-----\n([^\-]+)\n-----END CERTIFICATE-----/))) { var encoded = m[1]; return new Buffer(encoded, 'base64'); } else { throw new Error('Invalid PEM certificate'); } } /** * options: { * optional 'announce': tracker_url, * optional 'announce-list': [[tracker_url]], * optional 'similar': [info_hash_hex], * optional 'collections': [collection_name], * optional 'signing': { * 'key': key_string (PEM), * 'signCallack': (buf -> promise buf) as alternative to key, * optional 'cert': cert_string (PEM), * optional 'info': Object * } * optional 'originator': cert_string * } **/ var Torrent = function (base, name, files, options) { memoizeAll(this); this.base = base; this.name = name; this.files = files; this.options = options || {}; }; Torrent.prototype.getMetadata = function () { var defer = q.defer(); var getInfoRequest = this.getInfo(); var getAnnounceRequest = this.getAnnounce(); var getAnnounceListRequest = this.getAnnounceList(); var getSignaturesRequest = this.getSignatures(); var getUrlListRequest = this.getUrlList(); promiseLog.trace(getInfoRequest, 'get info'); promiseLog.trace(getAnnounceRequest, 'get announce'); promiseLog.trace(getAnnounceListRequest, 'get announce list'); promiseLog.trace(getSignaturesRequest, 'get signatures'); promiseLog.trace(getUrlListRequest, 'get url list'); getInfoRequest.progress(function (progress) { defer.notify(progress); }); var allRequests = q.all([ getInfoRequest, getAnnounceRequest, getAnnounceListRequest, getUrlListRequest, getSignaturesRequest ]); allRequests.spread(function (info, announce, announceList, urlList, signatures) { var data = { info: info, announce: announce, 'announce-list': announceList, 'url-list': urlList, 'creation date': Math.floor(Date.now() / 1000) }; /* BEP-35: Torrent Signing */ if (signatures) { data.signatures = signatures; } var metadata = bencode.encode(data); defer.resolve(metadata); }); allRequests.fail(function (err) { defer.reject(err); }); return defer.promise; }; Torrent.prototype.getPrivate = function () { return q.resolve(!!this.options.private); }; Torrent.prototype.getOriginator = function () { return q.resolve(this.options.originator); }; Torrent.prototype.getUpdateUrl = function () { return q.resolve(); }; Torrent.prototype.getAnnounce = function () { return q.resolve(this.options.announce || DEFAULT_ANNOUNCE); }; Torrent.prototype.getAnnounceList = function () { return q.resolve(this.options['announce-list'] || DEFAULT_ANNOUNCE_LIST); }; Torrent.prototype.getSimilar = function () { var similar = this.options.similar; return q.resolve( similar && similar.map(function (s) { return new Buffer(s, 'hex'); }) ); }; Torrent.prototype.getCollections = function () { return q.resolve(this.options.collections); }; Torrent.prototype.getInfo = function () { var defer = q.defer(); var getNameRequest = this.getName(); var getPieceLengthRequest = this.getPieceLength(); var getPiecesRequest = this.getPieces(); var getFilesRequest = this.getFiles(); var getOriginatorRequest = this.getOriginator(); var getUpdateUrlRequest = this.getUpdateUrl(); var getSimilarRequest = this.getSimilar(); var getCollectionsRequest = this.getCollections(); var getPrivateRequest = this.getPrivate(); promiseLog.trace(getNameRequest, 'get name'); promiseLog.trace(getPieceLengthRequest, 'get piece length'); promiseLog.trace(getPiecesRequest, 'get pieces'); promiseLog.trace(getFilesRequest, 'get files'); promiseLog.trace(getOriginatorRequest, 'get originator'); promiseLog.trace(getUpdateUrlRequest, 'get update url'); promiseLog.trace(getSimilarRequest, 'get similar'); promiseLog.trace(getCollectionsRequest, 'get collections'); promiseLog.trace(getPrivateRequest, 'get private'); getPiecesRequest.progress(function (progress) { defer.notify(progress); }); var allRequests = q.all([ getNameRequest, getPieceLengthRequest, getPiecesRequest, getFilesRequest, getOriginatorRequest, getUpdateUrlRequest, getSimilarRequest, getCollectionsRequest, getPrivateRequest ]); allRequests.spread(function ( name, pieceLength, pieces, files, originator, updateUrl, similar, collections, priv ) { var res = { name: name, 'piece length': pieceLength, pieces: pieces, files: files }; /* BEP-27: Private Torrents */ if (priv) { res['private'] = 1; } /* BEP-39: Updating Torrents Via Feed URL */ if (originator) { res.originator = pemToDer(originator); } if (updateUrl) { res['update-url'] = updateUrl; } /* BEP-38: Finding Local Data Via Torrent File Hints */ if (similar) { res.similar = similar; } if (collections) { res.collections = collections; } defer.resolve(res); }); allRequests.fail(function (err) { defer.reject(err); }); return defer.promise; }; Torrent.prototype.getSigning = function () { return q.resolve(this.options.signing); }; Torrent.prototype.getSigningAlgorithm = function () { /* ut-signing-tool uses SHA1 */ return q.resolve('RSA-SHA1'); }; function createDefaultSignCallback(algorithm, key) { return function (buf) { var rsaSign = crypto.createSign(algorithm); rsaSign.update(buf); return q.resolve(rsaSign.sign(key)); }; } Torrent.prototype.getSignatures = function () { return q.all([ this.getInfo(), this.getSigning(), this.getSigningAlgorithm() ]).spread(function (info, signing, algorithm) { return q.all( _.map(signing || {}, function (signingOptions, signingEntity) { var signCallback = signingOptions.signCallback || createDefaultSignCallback(algorithm, signingOptions.key); var signatureDict = {}; var buf = bencode.encode(info); if (signingOptions.info) { buf = Buffer.concat([buf, bencode.encode(signingOptions.info)]); signatureDict.info = signingOptions.info; } if (signingOptions.cert) { signatureDict.certificate = pemToDer(signingOptions.cert); } return q.resolve().then(function () { return signCallback(buf); }).then(function (signature) { signatureDict.signature = signature; return q.resolve({ entity: signingEntity, dict: signatureDict }); }); }) ); }).then(function (sigList) { if (sigList.length > 0) { var signatures = {}; sigList.forEach(function (sig) { signatures[sig.entity] = sig.dict; }); return signatures; } }); }; Torrent.prototype.getInfoHash = function () { return q.all([ this.getInfo() ]).spread(function (info) { var shasum = crypto.createHash('sha1'); shasum.update(bencode.encode(info)); var infoHash = shasum.digest('hex'); return q.resolve(infoHash); }); }; Torrent.prototype.getUrlList = function () { return q.resolve([this.base]); }; Torrent.prototype.getName = function () { return q.resolve(this.name); }; Torrent.prototype.getPieceLength = function () { return q.all([ this.getTotalContentSize() ]).spread(function (totalContentSize) { var unalignedPieceSize = Math.min(Math.max(totalContentSize / MAX_SUGGESTED_NUM_PIECES, MIN_PIECE_SIZE), MAX_PIECE_SIZE); // get an even number exponent that we can use to generate piece sizes that are multiples of 4 var rawExponent = Math.log(unalignedPieceSize) / Math.log(PIECE_SIZE_EXPONENT_MULTIPLE); var ceiledExponent = Math.ceil(rawExponent); var alignedPieceSize = Math.pow(PIECE_SIZE_EXPONENT_MULTIPLE, ceiledExponent); return q.resolve(alignedPieceSize); }); }; Torrent.prototype.getPieces = function () { return q.all([ this.getPieceLength(), this.getTotalContentSize() ]).spread(function (pieceLength, totalContentSize) { var expectedPieceCount = Math.ceil(totalContentSize / pieceLength); var httpFiles = _.map(this.files, function (file) { return new HttpFile(this.base + this.name + '/' + file); }.bind(this)); var hasher = new HttpHasher(httpFiles, pieceLength); return hasher.hash().then(function (pieceHashes) { assert(expectedPieceCount === pieceHashes.length, 'incorrect number of pieces generated'); var pieces = _.reduce(pieceHashes, function (memo, hash) { return memo + hash; }, ''); assert(pieces.length % 20 === 0, 'incorrect pieces length'); return new Buffer(pieces, 'hex'); }); }.bind(this)); }; Torrent.prototype.getTotalContentSize = function () { return q.all([ this.getFiles() ]).spread(function (files) { return _.reduce(files, function (memo, file) { return memo + file.length; }, 0); }); }; Torrent.prototype.getFiles = function () { return qsequence(_.map(this.files, function (file) { return function () { var httpFile = new HttpFile(this.base + this.name + '/' + file); return httpFile.size().then(function (size) { return q.resolve({ path: file.split('/'), length: size }); }); }.bind(this); }.bind(this))); }; module.exports = Torrent;