Anonymous 3D Imageboard http://cyberia.digital/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1133 lines
36KB

  1. <?php
  2. /**
  3. * Torrent.
  4. *
  5. * PHP version 5.2+ (with cURL extention enabled)
  6. *
  7. * 1) Features:
  8. * - Decode torrent file or data from local file and distant url
  9. * - Build torrent from source folder/file(s) or distant url
  10. * - Super easy usage & syntax
  11. * - Silent Exception error system
  12. *
  13. * 2) Usage example
  14. * <code>
  15. * require_once 'Torrent.php';
  16. *
  17. * // get torrent infos
  18. * $torrent = new Torrent( './test.torrent' );
  19. * echo '<br>private: ', $torrent->is_private() ? 'yes' : 'no',
  20. * '<br>announce: ', $torrent->announce(),
  21. * '<br>name: ', $torrent->name(),
  22. * '<br>comment: ', $torrent->comment(),
  23. * '<br>piece_length: ', $torrent->piece_length(),
  24. * '<br>size: ', $torrent->size( 2 ),
  25. * '<br>hash info: ', $torrent->hash_info(),
  26. * '<br>stats: ';
  27. * var_dump( $torrent->scrape() );
  28. * echo '<br>content: ';
  29. * var_dump( $torrent->content() );
  30. * echo '<br>source: ',
  31. * $torrent;
  32. *
  33. * // get magnet link
  34. * $torrent->magnet(); // use $torrent->magnet( false ); to get non html encoded ampersand
  35. *
  36. * // create torrent
  37. * $torrent = new Torrent( array( 'test.mp3', 'test.jpg' ), 'http://torrent.tracker/annonce' );
  38. * $torrent->save('test.torrent'); // save to disk
  39. *
  40. * // modify torrent
  41. * $torrent->announce('http://alternate-torrent.tracker/annonce'); // add a tracker
  42. * $torrent->announce(false); // reset announce trackers
  43. * $torrent->announce(array('http://torrent.tracker/annonce', 'http://alternate-torrent.tracker/annonce')); // set tracker(s), it also works with a 'one tracker' array...
  44. * $torrent->announce(array(array('http://torrent.tracker/annonce', 'http://alternate-torrent.tracker/annonce'), 'http://another-torrent.tracker/annonce')); // set tiered trackers
  45. * $torrent->comment('hello world');
  46. * $torrent->name('test torrent');
  47. * $torrent->is_private(true);
  48. * $torrent->httpseeds('http://file-hosting.domain/path/'); // BitTornado implementation
  49. * $torrent->url_list(array('http://file-hosting.domain/path/','http://another-file-hosting.domain/path/')); //
  50. * GetRight implementation
  51. *
  52. * // print errors
  53. * if ( $errors = $torrent->errors() )
  54. * var_dump( $errors );
  55. *
  56. * // send to user
  57. * $torrent->send();
  58. * </code>
  59. *
  60. * @author Adrien Gibrat <adrien.gibrat@gmail.com>
  61. * @tester Jeong, Anton, dokcharlie, official testers ;) Thanks for your precious feedback
  62. * @copyleft 2010 - Just use it!
  63. *
  64. * @license http://www.gnu.org/licenses/gpl.html GNU General Public License version 3
  65. *
  66. * @version 0.0.3
  67. */
  68. class Torrent
  69. {
  70. /**
  71. * @const float Default http timeout
  72. */
  73. const timeout = 30;
  74. /**
  75. * @var array List of error occurred
  76. */
  77. protected static $_errors = [];
  78. /** Read and decode torrent file/data OR build a torrent from source folder/file(s)
  79. * Supported signatures:
  80. * - Torrent(); // get an instance (useful to scrape and check errors)
  81. * - Torrent( string $torrent ); // analyze a torrent file
  82. * - Torrent( string $torrent, string $announce );
  83. * - Torrent( string $torrent, array $meta );
  84. * - Torrent( string $file_or_folder ); // create a torrent file
  85. * - Torrent( string $file_or_folder, string $announce_url, [int $piece_length] );
  86. * - Torrent( string $file_or_folder, array $meta, [int $piece_length] );
  87. * - Torrent( array $files_list );
  88. * - Torrent( array $files_list, string $announce_url, [int $piece_length] );
  89. * - Torrent( array $files_list, array $meta, [int $piece_length] );.
  90. *
  91. * @param string|array torrent to read or source folder/file(s) (optional, to get an instance)
  92. * @param string|array announce url or meta informations (optional)
  93. * @param int piece length (optional)
  94. */
  95. public function __construct($data = null, $meta = [], $piece_length = 256)
  96. {
  97. if (is_null($data)) {
  98. return false;
  99. }
  100. if ($piece_length < 32 || $piece_length > 4096) {
  101. return self::set_error(new Exception('Invalid piece length, must be between 32 and 4096'));
  102. }
  103. if (is_string($meta)) {
  104. $meta = ['announce' => $meta];
  105. }
  106. if ($this->build($data, $piece_length * 1024)) {
  107. $this->touch();
  108. } else {
  109. $meta = array_merge($meta, $this->decode($data));
  110. }
  111. foreach ($meta as $key => $value) {
  112. $this->{trim($key)} = $value;
  113. }
  114. }
  115. /** Convert the current Torrent instance in torrent format
  116. *
  117. * @return string encoded torrent data
  118. */
  119. public function __toString()
  120. {
  121. return $this->encode($this);
  122. }
  123. /** Return last error message
  124. *
  125. * @return string|bool last error message or false if none
  126. */
  127. public function error()
  128. {
  129. return empty(self::$_errors) ?
  130. false :
  131. self::$_errors[0]->getMessage();
  132. }
  133. /** Return Errors
  134. *
  135. * @return array|bool error list or false if none
  136. */
  137. public function errors()
  138. {
  139. return empty(self::$_errors) ?
  140. false :
  141. self::$_errors;
  142. }
  143. /**** Getters and setters ****/
  144. /** Getter and setter of torrent announce url / list
  145. * If the argument is a string, announce url is added to announce list (or set as announce if announce is not set)
  146. * If the argument is an array/object, set announce url (with first url) and list (if array has more than one url), tiered list supported
  147. * If the argument is false announce url & list are unset.
  148. *
  149. * @param null|false|string|array announce url / list, reset all if false (optional, if omitted it's a getter)
  150. *
  151. * @return string|array|null announce url / list or null if not set
  152. */
  153. public function announce($announce = null)
  154. {
  155. if (is_null($announce)) {
  156. return !isset($this->{'announce-list'}) ?
  157. isset($this->announce) ? $this->announce : null :
  158. $this->{'announce-list'};
  159. }
  160. $this->touch();
  161. if (is_string($announce) && isset($this->announce)) {
  162. return $this->{'announce-list'} = self::announce_list(isset($this->{'announce-list'}) ? $this->{'announce-list'} : $this->announce, $announce);
  163. }
  164. unset($this->{'announce-list'});
  165. if (is_array($announce) || is_object($announce)) {
  166. if (($this->announce = self::first_announce($announce)) && count($announce) > 1) {
  167. return $this->{'announce-list'} = self::announce_list($announce);
  168. } else {
  169. return $this->announce;
  170. }
  171. }
  172. if (!isset($this->announce) && $announce) {
  173. return $this->announce = (string) $announce;
  174. }
  175. unset($this->announce);
  176. }
  177. /** Getter and setter of torrent creation date
  178. *
  179. * @param null|int timestamp (optional, if omitted it's a getter)
  180. *
  181. * @return int|null timestamp or null if not set
  182. */
  183. public function creation_date($timestamp = null)
  184. {
  185. return is_null($timestamp) ?
  186. isset($this->{'creation date'}) ? $this->{'creation date'} : null :
  187. $this->touch($this->{'creation date'} = (int) $timestamp);
  188. }
  189. /** Getter and setter of torrent comment
  190. *
  191. * @param null|string comment (optional, if omitted it's a getter)
  192. *
  193. * @return string|null comment or null if not set
  194. */
  195. public function comment($comment = null)
  196. {
  197. return is_null($comment) ?
  198. isset($this->comment) ? $this->comment : null :
  199. $this->touch($this->comment = (string) $comment);
  200. }
  201. /** Getter and setter of torrent name
  202. *
  203. * @param null|string name (optional, if omitted it's a getter)
  204. *
  205. * @return string|null name or null if not set
  206. */
  207. public function name($name = null)
  208. {
  209. return is_null($name) ?
  210. isset($this->info['name']) ? $this->info['name'] : null :
  211. $this->touch($this->info['name'] = (string) $name);
  212. }
  213. /** Getter and setter of private flag
  214. *
  215. * @param null|bool is private or not (optional, if omitted it's a getter)
  216. *
  217. * @return bool private flag
  218. */
  219. public function is_private($private = null)
  220. {
  221. return is_null($private) ?
  222. !empty($this->info['private']) :
  223. $this->touch($this->info['private'] = $private ? 1 : 0);
  224. }
  225. /** Getter and setter of torrent source
  226. *
  227. * @param null|string source (optional, if omitted it's a getter)
  228. *
  229. * @return string|null source or null if not set
  230. */
  231. public function source($source = null)
  232. {
  233. return is_null($source) ?
  234. isset($this->info['source']) ? $this->info['source'] : null :
  235. $this->touch($this->info['source'] = (string) $source);
  236. }
  237. /** Getter and setter of webseed(s) url list ( GetRight implementation )
  238. *
  239. * @param null|string|array webseed or webseeds mirror list (optional, if omitted it's a getter)
  240. *
  241. * @return string|array|null webseed(s) or null if not set
  242. */
  243. public function url_list($urls = null)
  244. {
  245. return is_null($urls) ?
  246. isset($this->{'url-list'}) ? $this->{'url-list'} : null :
  247. $this->touch($this->{'url-list'} = is_string($urls) ? $urls : (array) $urls);
  248. }
  249. /** Getter and setter of httpseed(s) url list ( BitTornado implementation )
  250. *
  251. * @param null|string|array httpseed or httpseeds mirror list (optional, if omitted it's a getter)
  252. *
  253. * @return array|null httpseed(s) or null if not set
  254. */
  255. public function httpseeds($urls = null)
  256. {
  257. return is_null($urls) ?
  258. isset($this->httpseeds) ? $this->httpseeds : null :
  259. $this->touch($this->httpseeds = (array) $urls);
  260. }
  261. /**** Analyze BitTorrent ****/
  262. /** Get piece length
  263. *
  264. * @return int piece length or null if not set
  265. */
  266. public function piece_length()
  267. {
  268. return isset($this->info['piece length']) ?
  269. $this->info['piece length'] :
  270. null;
  271. }
  272. /** Compute hash info
  273. *
  274. * @return string hash info or null if info not set
  275. */
  276. public function hash_info()
  277. {
  278. return isset($this->info) ?
  279. sha1(self::encode($this->info)) :
  280. null;
  281. }
  282. /** List torrent content
  283. *
  284. * @param int|null size precision (optional, if omitted returns sizes in bytes)
  285. *
  286. * @return array file(s) and size(s) list, files as keys and sizes as values
  287. */
  288. public function content($precision = null)
  289. {
  290. $files = [];
  291. if (isset($this->info['files']) && is_array($this->info['files'])) {
  292. foreach ($this->info['files'] as $file) {
  293. $files[self::path($file['path'], $this->info['name'])] = $precision ?
  294. self::format($file['length'], $precision) :
  295. $file['length'];
  296. }
  297. } elseif (isset($this->info['name'])) {
  298. $files[$this->info['name']] = $precision ?
  299. self::format($this->info['length'], $precision) :
  300. $this->info['length'];
  301. }
  302. return $files;
  303. }
  304. /** List torrent content pieces and offset(s)
  305. *
  306. * @return array file(s) and pieces/offset(s) list, file(s) as keys and pieces/offset(s) as values
  307. */
  308. public function offset()
  309. {
  310. $files = [];
  311. $size = 0;
  312. if (isset($this->info['files']) && is_array($this->info['files'])) {
  313. foreach ($this->info['files'] as $file) {
  314. $files[self::path($file['path'], $this->info['name'])] = [
  315. 'startpiece' => floor($size / $this->info['piece length']),
  316. 'offset' => fmod($size, $this->info['piece length']),
  317. 'size' => $size += $file['length'],
  318. 'endpiece' => floor($size / $this->info['piece length']),
  319. ];
  320. }
  321. } elseif (isset($this->info['name'])) {
  322. $files[$this->info['name']] = [
  323. 'startpiece' => 0,
  324. 'offset' => 0,
  325. 'size' => $this->info['length'],
  326. 'endpiece' => floor($this->info['length'] / $this->info['piece length']),
  327. ];
  328. }
  329. return $files;
  330. }
  331. /** Sum torrent content size
  332. *
  333. * @param int|null size precision (optional, if omitted returns size in bytes)
  334. *
  335. * @return int|string file(s) size
  336. */
  337. public function size($precision = null)
  338. {
  339. $size = 0;
  340. if (isset($this->info['files']) && is_array($this->info['files'])) {
  341. foreach ($this->info['files'] as $file) {
  342. $size += $file['length'];
  343. }
  344. } elseif (isset($this->info['name'])) {
  345. $size = $this->info['length'];
  346. }
  347. return is_null($precision) ?
  348. $size :
  349. self::format($size, $precision);
  350. }
  351. /** Request torrent statistics from scrape page USING CURL!!
  352. *
  353. * @param string|array announce or scrape page url (optional, to request an alternative tracker BUT required for static call)
  354. * @param string torrent hash info (optional, required ONLY for static call)
  355. * @param float read timeout in seconds (optional, default to self::timeout 30s)
  356. *
  357. * @return array tracker torrent statistics
  358. */
  359. /* static */
  360. public function scrape($announce = null, $hash_info = null, $timeout = self::timeout)
  361. {
  362. $packed_hash = urlencode(pack('H*', $hash_info ? $hash_info : $this->hash_info()));
  363. $handles = $scrape = [];
  364. if (!function_exists('curl_multi_init')) {
  365. return self::set_error(new Exception('Install CURL with "curl_multi_init" enabled'));
  366. }
  367. $curl = curl_multi_init();
  368. foreach ((array) ($announce ? $announce : $this->announce()) as $tier) {
  369. foreach ((array) $tier as $tracker) {
  370. $tracker = str_ireplace([
  371. 'udp://',
  372. '/announce',
  373. ':80/',
  374. ], [
  375. 'http://',
  376. '/scrape',
  377. '/',
  378. ], $tracker);
  379. if (isset($handles[$tracker])) {
  380. continue;
  381. }
  382. $handles[$tracker] = curl_init($tracker . '?info_hash=' . $packed_hash);
  383. curl_setopt($handles[$tracker], CURLOPT_RETURNTRANSFER, true);
  384. curl_setopt($handles[$tracker], CURLOPT_TIMEOUT, $timeout);
  385. curl_multi_add_handle($curl, $handles[$tracker]);
  386. }
  387. }
  388. do {
  389. while (CURLM_CALL_MULTI_PERFORM == ($state = curl_multi_exec($curl, $running)));
  390. if (CURLM_OK != $state) {
  391. continue;
  392. }
  393. while ($done = curl_multi_info_read($curl)) {
  394. $info = curl_getinfo($done['handle']);
  395. $tracker = explode('?', $info['url'], 2);
  396. $tracker = array_shift($tracker);
  397. if (empty($info['http_code'])) {
  398. $scrape[$tracker] = self::set_error(new Exception('Tracker request timeout (' . $timeout . 's)'), true);
  399. continue;
  400. } elseif (200 != $info['http_code']) {
  401. $scrape[$tracker] = self::set_error(new Exception('Tracker request failed (' . $info['http_code'] . ' code)'), true);
  402. continue;
  403. }
  404. $data = curl_multi_getcontent($done['handle']);
  405. $stats = self::decode_data($data);
  406. curl_multi_remove_handle($curl, $done['handle']);
  407. $scrape[$tracker] = empty($stats['files']) ?
  408. self::set_error(new Exception('Empty scrape data'), true) :
  409. array_shift($stats['files']) + (empty($stats['flags']) ? [] : $stats['flags']);
  410. }
  411. } while ($running);
  412. curl_multi_close($curl);
  413. return $scrape;
  414. }
  415. /**** Save and Send ****/
  416. /** Save torrent file to disk
  417. *
  418. * @param null|string name of the file (optional)
  419. *
  420. * @return bool file has been saved or not
  421. */
  422. public function save($filename = null)
  423. {
  424. return file_put_contents(is_null($filename) ? $this->info['name'] . '.torrent' : $filename, $this->encode($this));
  425. }
  426. /** Send torrent file to client
  427. *
  428. * @param null|string name of the file (optional)
  429. */
  430. public function send($filename = null)
  431. {
  432. $data = $this->encode($this);
  433. header('Content-type: application/x-bittorrent');
  434. header('Content-Length: ' . strlen($data));
  435. header('Content-Disposition: attachment; filename="' . (is_null($filename) ? $this->info['name'] . '.torrent' : $filename) . '"');
  436. exit($data);
  437. }
  438. /** Get magnet link
  439. *
  440. * @param bool html encode ampersand, default true (optional)
  441. *
  442. * @return string magnet link
  443. */
  444. public function magnet($html = true)
  445. {
  446. $ampersand = $html ? '&amp;' : '&';
  447. return sprintf('magnet:?xt=urn:btih:%2$s%1$sdn=%3$s%1$sxl=%4$d%1$str=%5$s', $ampersand, $this->hash_info(), urlencode($this->name()), $this->size(), implode($ampersand . 'tr=', self::untier($this->announce())));
  448. }
  449. /**** Encode BitTorrent ****/
  450. /** Encode torrent data
  451. *
  452. * @param mixed data to encode
  453. *
  454. * @return string torrent encoded data
  455. */
  456. public static function encode($mixed)
  457. {
  458. switch (gettype($mixed)) {
  459. case 'integer':
  460. case 'double':
  461. return self::encode_integer($mixed);
  462. case 'object':
  463. $mixed = get_object_vars($mixed);
  464. // no break
  465. case 'array':
  466. return self::encode_array($mixed);
  467. default:
  468. return self::encode_string((string) $mixed);
  469. }
  470. }
  471. /** Encode torrent string
  472. *
  473. * @param string string to encode
  474. *
  475. * @return string encoded string
  476. */
  477. private static function encode_string($string)
  478. {
  479. return strlen($string) . ':' . $string;
  480. }
  481. /** Encode torrent integer
  482. *
  483. * @param int integer to encode
  484. *
  485. * @return string encoded integer
  486. */
  487. private static function encode_integer($integer)
  488. {
  489. return 'i' . $integer . 'e';
  490. }
  491. /** Encode torrent dictionary or list
  492. *
  493. * @param array array to encode
  494. *
  495. * @return string encoded dictionary or list
  496. */
  497. private static function encode_array($array)
  498. {
  499. if (self::is_list($array)) {
  500. $return = 'l';
  501. foreach ($array as $value) {
  502. $return .= self::encode($value);
  503. }
  504. } else {
  505. ksort($array, SORT_STRING);
  506. $return = 'd';
  507. foreach ($array as $key => $value) {
  508. $return .= self::encode(strval($key)) . self::encode($value);
  509. }
  510. }
  511. return $return . 'e';
  512. }
  513. /**** Decode BitTorrent ****/
  514. /** Decode torrent data or file
  515. *
  516. * @param string data or file path to decode
  517. *
  518. * @return array decoded torrent data
  519. */
  520. protected static function decode($string)
  521. {
  522. $data = is_file($string) || self::url_exists($string) ?
  523. self::file_get_contents($string) :
  524. $string;
  525. return (array) self::decode_data($data);
  526. }
  527. /** Decode torrent data
  528. *
  529. * @param string data to decode
  530. *
  531. * @return array decoded torrent data
  532. */
  533. private static function decode_data(&$data)
  534. {
  535. switch (self::char($data)) {
  536. case 'i':
  537. $data = substr($data, 1);
  538. return self::decode_integer($data);
  539. case 'l':
  540. $data = substr($data, 1);
  541. return self::decode_list($data);
  542. case 'd':
  543. $data = substr($data, 1);
  544. return self::decode_dictionary($data);
  545. default:
  546. return self::decode_string($data);
  547. }
  548. }
  549. /** Decode torrent dictionary
  550. *
  551. * @param string data to decode
  552. *
  553. * @return array decoded dictionary
  554. */
  555. private static function decode_dictionary(&$data)
  556. {
  557. $dictionary = [];
  558. $previous = null;
  559. while ('e' != ($char = self::char($data))) {
  560. if (false === $char) {
  561. return self::set_error(new Exception('Unterminated dictionary'));
  562. }
  563. if (!ctype_digit($char)) {
  564. return self::set_error(new Exception('Invalid dictionary key'));
  565. }
  566. $key = self::decode_string($data);
  567. if (isset($dictionary[$key])) {
  568. return self::set_error(new Exception('Duplicate dictionary key'));
  569. }
  570. if ($key < $previous) {
  571. self::set_error(new Exception('Missorted dictionary key'));
  572. }
  573. $dictionary[$key] = self::decode_data($data);
  574. $previous = $key;
  575. }
  576. $data = substr($data, 1);
  577. return $dictionary;
  578. }
  579. /** Decode torrent list
  580. *
  581. * @param string data to decode
  582. *
  583. * @return array decoded list
  584. */
  585. private static function decode_list(&$data)
  586. {
  587. $list = [];
  588. while ('e' != ($char = self::char($data))) {
  589. if (false === $char) {
  590. return self::set_error(new Exception('Unterminated list'));
  591. }
  592. $list[] = self::decode_data($data);
  593. }
  594. $data = substr($data, 1);
  595. return $list;
  596. }
  597. /** Decode torrent string
  598. *
  599. * @param string data to decode
  600. *
  601. * @return string decoded string
  602. */
  603. private static function decode_string(&$data)
  604. {
  605. if ('0' === self::char($data) && ':' != substr($data, 1, 1)) {
  606. self::set_error(new Exception('Invalid string length, leading zero'));
  607. }
  608. if (!$colon = @strpos($data, ':')) {
  609. return self::set_error(new Exception('Invalid string length, colon not found'));
  610. }
  611. $length = intval(substr($data, 0, $colon));
  612. if ($length + $colon + 1 > strlen($data)) {
  613. return self::set_error(new Exception('Invalid string, input too short for string length'));
  614. }
  615. $string = substr($data, $colon + 1, $length);
  616. $data = substr($data, $colon + $length + 1);
  617. return $string;
  618. }
  619. /** Decode torrent integer
  620. *
  621. * @param string data to decode
  622. *
  623. * @return int decoded integer
  624. */
  625. private static function decode_integer(&$data)
  626. {
  627. $start = 0;
  628. $end = strpos($data, 'e');
  629. if (0 === $end) {
  630. self::set_error(new Exception('Empty integer'));
  631. }
  632. if ('-' == self::char($data)) {
  633. ++$start;
  634. }
  635. if ('0' == substr($data, $start, 1) && $end > $start + 1) {
  636. self::set_error(new Exception('Leading zero in integer'));
  637. }
  638. if (!ctype_digit(substr($data, $start, $start ? $end - 1 : $end))) {
  639. self::set_error(new Exception('Non-digit characters in integer'));
  640. }
  641. $integer = substr($data, 0, $end);
  642. $data = substr($data, $end + 1);
  643. return 0 + $integer;
  644. }
  645. /**** Internal Helpers ****/
  646. /** Build torrent info
  647. *
  648. * @param string|array source folder/file(s) path
  649. * @param int piece length
  650. *
  651. * @return array|bool torrent info or false if data isn't folder/file(s)
  652. */
  653. protected function build($data, $piece_length)
  654. {
  655. if (is_null($data)) {
  656. return false;
  657. } elseif (is_array($data) && self::is_list($data)) {
  658. return $this->info = $this->files($data, $piece_length);
  659. } elseif (is_dir($data)) {
  660. return $this->info = $this->folder($data, $piece_length);
  661. } elseif ((is_file($data) || self::url_exists($data)) && !self::is_torrent($data)) {
  662. return $this->info = $this->file($data, $piece_length);
  663. } else {
  664. return false;
  665. }
  666. }
  667. /** Set torrent creator and creation date
  668. *
  669. * @param any param
  670. *
  671. * @return any param
  672. */
  673. protected function touch($void = null)
  674. {
  675. $this->{'created by'} = 'Torrent RW PHP Class - http://github.com/adriengibrat/torrent-rw';
  676. $this->{'creation date'} = time();
  677. return $void;
  678. }
  679. /** Add an error to errors stack
  680. *
  681. * @param Exception error to add
  682. * @param bool return error message or not (optional, default to false)
  683. *
  684. * @return bool|string return false or error message if requested
  685. */
  686. protected static function set_error($exception, $message = false)
  687. {
  688. return (array_unshift(self::$_errors, $exception) && $message) ? $exception->getMessage() : false;
  689. }
  690. /** Build announce list
  691. *
  692. * @param string|array announce url / list
  693. * @param string|array announce url / list to add (optionnal)
  694. *
  695. * @return array announce list (array of arrays)
  696. */
  697. protected static function announce_list($announce, $merge = [])
  698. {
  699. return array_map(function($a) {return (array) $a;}, array_merge((array) $announce, (array) $merge));
  700. }
  701. /** Get the first announce url in a list
  702. *
  703. * @param array announce list (array of arrays if tiered trackers)
  704. *
  705. * @return string first announce url
  706. */
  707. protected static function first_announce($announce)
  708. {
  709. while (is_array($announce)) {
  710. $announce = reset($announce);
  711. }
  712. return $announce;
  713. }
  714. /** Helper to pack data hash
  715. *
  716. * @param string data
  717. *
  718. * @return string packed data hash
  719. */
  720. protected static function pack(&$data)
  721. {
  722. return pack('H*', sha1($data)) . ($data = null);
  723. }
  724. /** Helper to build file path
  725. *
  726. * @param array file path
  727. * @param string base folder
  728. *
  729. * @return string real file path
  730. */
  731. protected static function path($path, $folder)
  732. {
  733. array_unshift($path, $folder);
  734. return join(DIRECTORY_SEPARATOR, $path);
  735. }
  736. /** Helper to explode file path
  737. *
  738. * @param string file path
  739. *
  740. * @return array file path
  741. */
  742. protected static function path_explode($path)
  743. {
  744. return explode(DIRECTORY_SEPARATOR, $path);
  745. }
  746. /** Helper to test if an array is a list
  747. *
  748. * @param array array to test
  749. *
  750. * @return bool is the array a list or not
  751. */
  752. protected static function is_list($array)
  753. {
  754. foreach (array_keys($array) as $key) {
  755. if (!is_int($key)) {
  756. return false;
  757. }
  758. }
  759. return true;
  760. }
  761. /** Build pieces depending on piece length from a file handler
  762. *
  763. * @param ressource file handle
  764. * @param int piece length
  765. * @param bool is last piece
  766. *
  767. * @return string pieces
  768. */
  769. private function pieces($handle, $piece_length, $last = true)
  770. {
  771. static $piece, $length;
  772. if (empty($length)) {
  773. $length = $piece_length;
  774. }
  775. $pieces = null;
  776. while (!feof($handle)) {
  777. if (($length = strlen($piece .= fread($handle, $length))) == $piece_length) {
  778. $pieces .= self::pack($piece);
  779. } elseif (($length = $piece_length - $length) < 0) {
  780. return self::set_error(new Exception('Invalid piece length!'));
  781. }
  782. }
  783. fclose($handle);
  784. return $pieces . ($last && $piece ? self::pack($piece) : null);
  785. }
  786. /** Build torrent info from single file
  787. *
  788. * @param string file path
  789. * @param int piece length
  790. *
  791. * @return array torrent info
  792. */
  793. private function file($file, $piece_length)
  794. {
  795. if (!$handle = self::fopen($file, $size = self::filesize($file))) {
  796. return self::set_error(new Exception('Failed to open file: "' . $file . '"'));
  797. }
  798. if (self::is_url($file)) {
  799. $this->url_list($file);
  800. }
  801. $path = self::path_explode($file);
  802. return [
  803. 'length' => $size,
  804. 'name' => end($path),
  805. 'piece length' => $piece_length,
  806. 'pieces' => $this->pieces($handle, $piece_length),
  807. ];
  808. }
  809. /** Build torrent info from files
  810. *
  811. * @param array file list
  812. * @param int piece length
  813. *
  814. * @return array torrent info
  815. */
  816. private function files($files, $piece_length)
  817. {
  818. sort($files);
  819. usort($files, function($a, $b) {
  820. return strrpos($a,DIRECTORY_SEPARATOR)-strrpos($b,DIRECTORY_SEPARATOR);
  821. });
  822. $first = current($files);
  823. if (!self::is_url($first)) {
  824. $files = array_map('realpath', $files);
  825. } else {
  826. $this->url_list(dirname($first) . DIRECTORY_SEPARATOR);
  827. }
  828. $files_path = array_map('self::path_explode', $files);
  829. $root = call_user_func_array('array_intersect_assoc', $files_path);
  830. $pieces = null;
  831. $info_files = [];
  832. $count = count($files) - 1;
  833. foreach ($files as $i => $file) {
  834. if (!$handle = self::fopen($file, $filesize = self::filesize($file))) {
  835. self::set_error(new Exception('Failed to open file: "' . $file . '" discarded'));
  836. continue;
  837. }
  838. $pieces .= $this->pieces($handle, $piece_length, $count == $i);
  839. $info_files[] = [
  840. 'length' => $filesize,
  841. 'path' => array_diff_assoc($files_path[$i], $root),
  842. ];
  843. }
  844. return [
  845. 'files' => $info_files,
  846. 'name' => end($root),
  847. 'piece length' => $piece_length,
  848. 'pieces' => $pieces,
  849. ];
  850. }
  851. /** Build torrent info from folder content
  852. *
  853. * @param string folder path
  854. * @param int piece length
  855. *
  856. * @return array torrent info
  857. */
  858. private function folder($dir, $piece_length)
  859. {
  860. return $this->files(self::scandir($dir), $piece_length);
  861. }
  862. /** Helper to return the first char of encoded data
  863. *
  864. * @param string encoded data
  865. *
  866. * @return string|bool first char of encoded data or false if empty data
  867. */
  868. private static function char($data)
  869. {
  870. return empty($data) ?
  871. false :
  872. substr($data, 0, 1);
  873. }
  874. /**** Public Helpers ****/
  875. /** Helper to format size in bytes to human readable
  876. *
  877. * @param int size in bytes
  878. * @param int precision after coma
  879. *
  880. * @return string formated size in appropriate unit
  881. */
  882. public static function format($size, $precision = 2)
  883. {
  884. $units = [
  885. 'octets',
  886. 'Ko',
  887. 'Mo',
  888. 'Go',
  889. 'To',
  890. ];
  891. while (($next = next($units)) && $size > 1024) {
  892. $size /= 1024;
  893. }
  894. return round($size, $precision) . ' ' . ($next ? prev($units) : end($units));
  895. }
  896. /** Helper to return filesize (even bigger than 2Gb -linux only- and distant files size)
  897. *
  898. * @param string file path
  899. *
  900. * @return float|bool filesize or false if error
  901. */
  902. public static function filesize($file)
  903. {
  904. if (is_file($file)) {
  905. return (float) sprintf('%u', @filesize($file));
  906. } elseif ($content_length = preg_grep($pattern = '#^Content-Length:\s+(\d+)$#i', (array) @get_headers($file))) {
  907. return (int) preg_replace($pattern, '$1', reset($content_length));
  908. }
  909. }
  910. /** Helper to open file to read (even bigger than 2Gb, linux only)
  911. *
  912. * @param string file path
  913. * @param int|float file size (optional)
  914. *
  915. * @return resource|bool file handle or false if error
  916. */
  917. public static function fopen($file, $size = null)
  918. {
  919. if ((is_null($size) ? self::filesize($file) : $size) <= 2 * pow(1024, 3)) {
  920. return fopen($file, 'r');
  921. } elseif (PHP_OS != 'Linux') {
  922. return self::set_error(new Exception('File size is greater than 2GB. This is only supported under Linux'));
  923. } elseif (!is_readable($file)) {
  924. return false;
  925. } else {
  926. return popen('cat ' . escapeshellarg(realpath($file)), 'r');
  927. }
  928. }
  929. /** Helper to scan directories files and sub directories recursively
  930. *
  931. * @param string directory path
  932. *
  933. * @return array directory content list
  934. */
  935. public static function scandir($dir)
  936. {
  937. $paths = [];
  938. foreach (scandir($dir) as $item) {
  939. if ('.' != $item && '..' != $item) {
  940. if (is_dir($path = realpath($dir . DIRECTORY_SEPARATOR . $item))) {
  941. $paths = array_merge(self::scandir($path), $paths);
  942. } else {
  943. $paths[] = $path;
  944. }
  945. }
  946. }
  947. return $paths;
  948. }
  949. /** Helper to check if string is an url (http)
  950. *
  951. * @param string url to check
  952. *
  953. * @return bool is string an url
  954. */
  955. public static function is_url($url)
  956. {
  957. return preg_match('#^http(s)?://[a-z0-9-]+(.[a-z0-9-]+)*(:[0-9]+)?(/.*)?$#i', $url);
  958. }
  959. /** Helper to check if url exists
  960. *
  961. * @param string url to check
  962. *
  963. * @return bool does the url exist or not
  964. */
  965. public static function url_exists($url)
  966. {
  967. return self::is_url($url) ?
  968. (bool) self::filesize($url) :
  969. false;
  970. }
  971. /** Helper to check if a file is a torrent
  972. *
  973. * @param string file location
  974. * @param float http timeout (optional, default to self::timeout 30s)
  975. *
  976. * @return bool is the file a torrent or not
  977. */
  978. public static function is_torrent($file, $timeout = self::timeout)
  979. {
  980. return ($start = self::file_get_contents($file, $timeout, 0, 11))
  981. && 'd8:announce' === $start
  982. || 'd10:created' === $start
  983. || 'd13:creatio' === $start
  984. || 'd13:announc' === $start
  985. || 'd12:_info_l' === $start
  986. || 'd7:comment' === substr($start, 0, 10) // @see https://github.com/adriengibrat/torrent-rw/issues/32
  987. || 'd4:info' === substr($start, 0, 7)
  988. || 'd9:' === substr($start, 0, 3); // @see https://github.com/adriengibrat/torrent-rw/pull/17
  989. }
  990. /** Helper to get (distant) file content
  991. *
  992. * @param string file location
  993. * @param float http timeout (optional, default to self::timeout 30s)
  994. * @param int starting offset (optional, default to null)
  995. * @param int content length (optional, default to null)
  996. *
  997. * @return string|bool file content or false if error
  998. */
  999. public static function file_get_contents($file, $timeout = self::timeout, $offset = null, $length = null)
  1000. {
  1001. if (is_file($file) || ini_get('allow_url_fopen')) {
  1002. $context = !is_file($file) && $timeout ?
  1003. stream_context_create(['http' => ['timeout' => $timeout]]) :
  1004. null;
  1005. return !is_null($offset) ? $length ?
  1006. @file_get_contents($file, false, $context, $offset, $length) :
  1007. @file_get_contents($file, false, $context, $offset) :
  1008. @file_get_contents($file, false, $context);
  1009. } elseif (!function_exists('curl_init')) {
  1010. return self::set_error(new Exception('Install CURL or enable "allow_url_fopen"'));
  1011. }
  1012. $handle = curl_init($file);
  1013. if ($timeout) {
  1014. curl_setopt($handle, CURLOPT_TIMEOUT, $timeout);
  1015. }
  1016. if ($offset || $length) {
  1017. curl_setopt($handle, CURLOPT_RANGE, $offset . '-' . ($length ? $offset + $length - 1 : null));
  1018. }
  1019. curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
  1020. $content = curl_exec($handle);
  1021. $size = curl_getinfo($handle, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
  1022. curl_close($handle);
  1023. return ($offset && $size == -1) || ($length && $length != $size) ? $length ?
  1024. substr($content, $offset, $length) :
  1025. substr($content, $offset) :
  1026. $content;
  1027. }
  1028. /** Flatten announces list
  1029. *
  1030. * @param array announces list
  1031. *
  1032. * @return array flattened announces list
  1033. */
  1034. public static function untier($announces)
  1035. {
  1036. $list = [];
  1037. foreach ((array) $announces as $tier) {
  1038. is_array($tier) ?
  1039. $list = array_merge($list, self::untier($tier)) :
  1040. array_push($list, $tier);
  1041. }
  1042. return $list;
  1043. }
  1044. }