webseeded-torrent-generator
Version:
Generate webseeded torrent files from urls to files.
382 lines (339 loc) • 11.8 kB
JavaScript
'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;