diff --git a/README.md b/README.md index b15d674..1dba59d 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,14 @@ git checkout -b my-pr-branch-name + [x] Sensitive + [ ] Comments + [ ] Features + + [x] Scrape trackers + + [x] Peers + + [x] Completed + + [x] Leechers + [x] Stars + [x] Downloads + [ ] Comments + [ ] Views - + [ ] Peers + [ ] Info page * [ ] User @@ -96,6 +99,7 @@ git checkout -b my-pr-branch-name #### Components [Icons](https://icons.getbootstrap.com) +[PHP Scrapper](https://github.com/medariox/scrapeer) #### Feedback diff --git a/database/yggtracker.mwb b/database/yggtracker.mwb index 5556714..c80cb79 100644 Binary files a/database/yggtracker.mwb and b/database/yggtracker.mwb differ diff --git a/example/environment/crontab b/example/environment/crontab index 0ec8c1d..f392715 100644 --- a/example/environment/crontab +++ b/example/environment/crontab @@ -1,4 +1,6 @@ @reboot searchd @reboot indexer --all --rotate -* * * * * indexer magnet --rotate \ No newline at end of file +* * * * * indexer magnet --rotate + +* * * * * /usr/bin/php /YGGtracker/src/crontab/scrape.php \ No newline at end of file diff --git a/src/config/app.php.example b/src/config/app.php.example index 2239bd5..2659267 100644 --- a/src/config/app.php.example +++ b/src/config/app.php.example @@ -100,3 +100,7 @@ define('TRACKER_LINKS', (object) // Yggdrasil define('YGGDRASIL_URL_REGEX', '/^0{0,1}[2-3][a-f0-9]{0,2}:/'); // thanks to @ygguser (https://github.com/YGGverse/YGGo/issues/1#issuecomment-1498182228 ) + +// Crawler +define('CRAWLER_SCRAPE_QUEUE_LIMIT', 1); +define('CRAWLER_SCRAPE_TIME_OFFLINE_TIMEOUT', 60*60*24); \ No newline at end of file diff --git a/src/crontab/scrape.php b/src/crontab/scrape.php new file mode 100644 index 0000000..14b9bda --- /dev/null +++ b/src/crontab/scrape.php @@ -0,0 +1,144 @@ + [ + 'ISO8601' => date('c'), + 'total' => microtime(true), + ], +]; + +// Connect DB +try { + + $db = new Database(DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD); + +} catch(Exception $e) { + + var_dump($e); + + exit; +} + +// Init Scraper +try { + + $scraper = new Scrapeer\Scraper(); + +} catch(Exception $e) { + + var_dump($e); + + exit; +} + +// Begin +try { + + $db->beginTransaction(); + + // Reset time offline by timeout + $db->resetMagnetToAddressTrackerTimeOfflineByTimeout( + CRAWLER_SCRAPE_TIME_OFFLINE_TIMEOUT + ); + + foreach ($db->getMagnetToAddressTrackerScrapeQueue(CRAWLER_SCRAPE_QUEUE_LIMIT) as $queue) + { + if ($addressTracker = $db->getAddressTracker($queue->addressTrackerId)) + { + // Build url + $scheme = $db->getScheme($addressTracker->schemeId); + $host = $db->getHost($addressTracker->hostId); + $port = $db->getPort($addressTracker->portId); + $uri = $db->getUri($addressTracker->uriId); + + $url = $port->value ? sprintf('%s://%s:%s%s', $scheme->value, + $host->value, + $port->value, + $uri->value) : sprintf('%s://%s%s', $scheme->value, + $host->value, + $uri->value); + + $hash = str_replace('urn:btih:', false, $db->getMagnet($queue->magnetId)->xt); + + if ($scrape = $scraper->scrape([$hash], [$url], null, 1)) + { + $db->updateMagnetToAddressTrackerTimeOffline( + $queue->magnetToAddressTrackerId, + null + ); + + if (isset($scrape[$hash]['seeders'])) + { + $db->updateMagnetToAddressTrackerSeeders( + $queue->magnetToAddressTrackerId, + (int) $scrape[$hash]['seeders'], + time() + ); + } + + if (isset($scrape[$hash]['completed'])) + { + $db->updateMagnetToAddressTrackerCompleted( + $queue->magnetToAddressTrackerId, + (int) $scrape[$hash]['completed'], + time() + ); + } + + if (isset($scrape[$hash]['leechers'])) + { + $db->updateMagnetToAddressTrackerLeechers( + $queue->magnetToAddressTrackerId, + (int) $scrape[$hash]['leechers'], + time() + ); + } + } + else + { + $db->updateMagnetToAddressTrackerTimeOffline( + $queue->magnetToAddressTrackerId, + time() + ); + } + } + } + + $db->commit(); + +} catch (EXception $e) { + + $db->rollback(); + + var_dump($e); +} + +// Debug output +$debug['time']['total'] = microtime(true) - $debug['time']['total']; + +print_r( + array_merge($debug, [ + 'db' => [ + 'total' => [ + 'select' => $db->getDebug()->query->select->total, + 'insert' => $db->getDebug()->query->insert->total, + 'update' => $db->getDebug()->query->update->total, + 'delete' => $db->getDebug()->query->delete->total, + ] + ] + ]) +); \ No newline at end of file diff --git a/src/library/database.php b/src/library/database.php index 9ce7bb4..cea50bf 100644 --- a/src/library/database.php +++ b/src/library/database.php @@ -653,6 +653,50 @@ class Database { return $this->_db->lastInsertId(); } + public function updateMagnetToAddressTrackerSeeders(int $magnetToAddressTrackerId, int $seeders, int $timeUpdated) : int { + + $this->_debug->query->update->total++; + + $query = $this->_db->prepare('UPDATE `magnetToAddressTracker` SET `seeders` = ?, `timeUpdated` = ? WHERE `magnetToAddressTrackerId` = ?'); + + $query->execute([$seeders, $timeUpdated, $magnetToAddressTrackerId]); + + return $query->rowCount(); + } + + public function updateMagnetToAddressTrackerCompleted(int $magnetToAddressTrackerId, int $completed, int $timeUpdated) : int { + + $this->_debug->query->update->total++; + + $query = $this->_db->prepare('UPDATE `magnetToAddressTracker` SET `completed` = ?, `timeUpdated` = ? WHERE `magnetToAddressTrackerId` = ?'); + + $query->execute([$completed, $timeUpdated, $magnetToAddressTrackerId]); + + return $query->rowCount(); + } + + public function updateMagnetToAddressTrackerLeechers(int $magnetToAddressTrackerId, int $leechers, int $timeUpdated) : int { + + $this->_debug->query->update->total++; + + $query = $this->_db->prepare('UPDATE `magnetToAddressTracker` SET `leechers` = ?, `timeUpdated` = ? WHERE `magnetToAddressTrackerId` = ?'); + + $query->execute([$leechers, $timeUpdated, $magnetToAddressTrackerId]); + + return $query->rowCount(); + } + + public function updateMagnetToAddressTrackerTimeOffline(int $magnetToAddressTrackerId, int $timeOffline) : int { + + $this->_debug->query->update->total++; + + $query = $this->_db->prepare('UPDATE `magnetToAddressTracker` SET `timeOffline` = ? WHERE `magnetToAddressTrackerId` = ?'); + + $query->execute([$timeOffline, $magnetToAddressTrackerId]); + + return $query->rowCount(); + } + public function deleteMagnetToAddressTrackerByMagnetId(int $magnetId) : int { $this->_debug->query->delete->total++; @@ -686,6 +730,38 @@ class Database { return $query->fetchAll(); } + public function getMagnetToAddressTrackerScrapeQueue(int $limit) { + + $this->_debug->query->select->total++; + + $query = $this->_db->prepare('SELECT * FROM `magnetToAddressTracker` + + WHERE `timeOffline` IS NULL + + ORDER BY `timeUpdated` ASC, RAND() + + LIMIT ' . (int) $limit); + + $query->execute(); + + return $query->fetchAll(); + } + + public function resetMagnetToAddressTrackerTimeOfflineByTimeout(int $timeOffline) : int { + + $this->_debug->query->update->total++; + + $query = $this->_db->prepare('UPDATE `magnetToAddressTracker` SET `timeOffline` = NULL WHERE `timeOffline` < ?'); + + $query->execute( + [ + time() - $timeOffline + ] + ); + + return $query->rowCount(); + } + public function initMagnetToAddressTrackerId(int $magnetId, int $addressTrackerId) : int { if ($result = $this->findMagnetToAddressTracker($magnetId, $addressTrackerId)) { @@ -942,28 +1018,17 @@ class Database { return $this->_db->lastInsertId(); } - public function getMagnetDownloadsTotal(int $magnetId) : int { + public function getMagnetDownloadsTotalByUserId(int $magnetId) : int { $this->_debug->query->select->total++; - $query = $this->_db->prepare('SELECT COUNT(*) AS `result` FROM `magnetDownload` WHERE `magnetId` = ?'); + $query = $this->_db->prepare('SELECT COUNT(DISTINCT `userId`) AS `result` FROM `magnetDownload` WHERE `magnetId` = ?'); $query->execute([$magnetId]); return $query->fetch()->result; } - public function deleteMagnetDownloadByUserId(int $magnetId, int $userId) : int { - - $this->_debug->query->delete->total++; - - $query = $this->_db->prepare('DELETE FROM `magnetDownload` WHERE `magnetId` = ? AND `userId` = ?'); - - $query->execute([$magnetId, $userId]); - - return $query->rowCount(); - } - public function findMagnetDownloadsTotalByUserId(int $magnetId, int $userId) : int { $this->_debug->query->select->total++; @@ -974,4 +1039,38 @@ class Database { return $query->fetch()->result; } + + // Magnet view + public function addMagnetView(int $magnetId, int $userId, int $timeAdded) : int { + + $this->_debug->query->insert->total++; + + $query = $this->_db->prepare('INSERT INTO `magnetView` SET `magnetId` = ?, `userId` = ?, `timeAdded` = ?'); + + $query->execute([$magnetId, $userId, $timeAdded]); + + return $this->_db->lastInsertId(); + } + + public function getMagnetViewsTotal(int $magnetId) : int { + + $this->_debug->query->select->total++; + + $query = $this->_db->prepare('SELECT COUNT(*) AS `result` FROM `magnetView` WHERE `magnetId` = ?'); + + $query->execute([$magnetId]); + + return $query->fetch()->result; + } + + public function findMagnetViewsTotalByUserId(int $magnetId, int $userId) : int { + + $this->_debug->query->select->total++; + + $query = $this->_db->prepare('SELECT COUNT(*) AS `result` FROM `magnetView` WHERE `magnetId` = ? AND `userId` = ?'); + + $query->execute([$magnetId, $userId]); + + return $query->fetch()->result; + } } \ No newline at end of file diff --git a/src/library/scrapeer.php b/src/library/scrapeer.php new file mode 100644 index 0000000..800c71d --- /dev/null +++ b/src/library/scrapeer.php @@ -0,0 +1,692 @@ +1) or string of infohash(es). + * @param array|string $trackers List (>1) or string of tracker(s). + * @param int|null $max_trackers Optional. Maximum number of trackers to be scraped, Default all. + * @param int $timeout Optional. Maximum time for each tracker scrape in seconds, Default 2. + * @param bool $announce Optional. Use announce instead of scrape, Default false. + * @return array List of results. + */ + public function scrape( $hashes, $trackers, $max_trackers = null, $timeout = 2, $announce = false ) { + $final_result = array(); + + if ( empty( $trackers ) ) { + $this->errors[] = 'No tracker specified, aborting.'; + return $final_result; + } else if ( ! is_array( $trackers ) ) { + $trackers = array( $trackers ); + } + + if ( is_int( $timeout ) ) { + $this->timeout = $timeout; + } else { + $this->timeout = 2; + $this->errors[] = 'Timeout must be an integer. Using default value.'; + } + + try { + $this->infohashes = $this->normalize_infohashes( $hashes ); + } catch ( \RangeException $e ) { + $this->errors[] = $e->getMessage(); + return $final_result; + } + + $max_iterations = is_int( $max_trackers ) ? $max_trackers : count( $trackers ); + foreach ( $trackers as $index => $tracker ) { + if ( ! empty( $this->infohashes ) && $index < $max_iterations ) { + $info = parse_url( $tracker ); + $protocol = $info['scheme']; + $host = $info['host']; + if ( empty( $protocol ) || empty( $host ) ) { + $this->errors[] = 'Skipping invalid tracker (' . $tracker . ').'; + continue; + } + + $port = isset( $info['port'] ) ? $info['port'] : null; + $path = isset( $info['path'] ) ? $info['path'] : null; + $passkey = $this->get_passkey( $path ); + $result = $this->try_scrape( $protocol, $host, $port, $passkey, $announce ); + $final_result = array_merge( $final_result, $result ); + continue; + } + break; + } + return $final_result; + } + + /** + * Normalizes the given hashes + * + * @throws \RangeException If amount of valid infohashes > 64 or < 1. + * + * @param array $infohashes List of infohash(es). + * @return array Normalized infohash(es). + */ + private function normalize_infohashes( $infohashes ) { + if ( ! is_array( $infohashes ) ) { + $infohashes = array( $infohashes ); + } + + foreach ( $infohashes as $index => $infohash ) { + if ( ! preg_match( '/^[a-f0-9]{40}$/i', $infohash ) ) { + $this->errors[] = 'Invalid infohash skipped (' . $infohash . ').'; + unset( $infohashes[ $index ] ); + } + } + + $total_infohashes = count( $infohashes ); + if ( $total_infohashes > 64 || $total_infohashes < 1 ) { + throw new \RangeException( 'Invalid amount of valid infohashes (' . $total_infohashes . ').' ); + } + + $infohashes = array_values( $infohashes ); + + return $infohashes; + } + + /** + * Returns the passkey found in the scrape request. + * + * @param string $path Path from the scrape request. + * @return string Passkey or empty string. + */ + private function get_passkey( $path ) { + if ( ! is_null( $path ) && preg_match( '/[a-z0-9]{32}/i', $path, $matches ) ) { + return '/' . $matches[0]; + } + + return ''; + } + + /** + * Tries to scrape with a single tracker. + * + * @throws \Exception In case of unsupported protocol. + * + * @param string $protocol Protocol of the tracker. + * @param string $host Domain or address of the tracker. + * @param int $port Optional. Port number of the tracker. + * @param string $passkey Optional. Passkey provided in the scrape request. + * @param bool $announce Optional. Use announce instead of scrape, Default false. + * @return array List of results. + */ + private function try_scrape( $protocol, $host, $port, $passkey, $announce ) { + $infohashes = $this->infohashes; + $this->infohashes = array(); + $results = array(); + try { + switch ( $protocol ) { + case 'udp': + $port = isset( $port ) ? $port : 80; + $results = $this->scrape_udp( $infohashes, $host, $port, $announce ); + break; + case 'http': + $port = isset( $port ) ? $port : 80; + $results = $this->scrape_http( $infohashes, $protocol, $host, $port, $passkey, $announce ); + break; + case 'https': + $port = isset( $port ) ? $port : 443; + $results = $this->scrape_http( $infohashes, $protocol, $host, $port, $passkey, $announce ); + break; + default: + throw new \Exception( 'Unsupported protocol (' . $protocol . '://' . $host . ').' ); + } + } catch ( \Exception $e ) { + $this->infohashes = $infohashes; + $this->errors[] = $e->getMessage(); + } + return $results; + } + + /** + * Initiates the HTTP(S) scraping + * + * @param array|string $infohashes List (>1) or string of infohash(es). + * @param string $protocol Protocol to use for the scraping. + * @param string $host Domain or IP address of the tracker. + * @param int $port Optional. Port number of the tracker, Default 80 (HTTP) or 443 (HTTPS). + * @param string $passkey Optional. Passkey provided in the scrape request. + * @param bool $announce Optional. Use announce instead of scrape, Default false. + * @return array List of results. + */ + private function scrape_http( $infohashes, $protocol, $host, $port, $passkey, $announce ) { + if ( true === $announce ) { + $response = $this->http_announce( $infohashes, $protocol, $host, $port, $passkey ); + } else { + $query = $this->http_query( $infohashes, $protocol, $host, $port, $passkey ); + $response = $this->http_request( $query, $host, $port ); + } + $results = $this->http_data( $response, $infohashes, $host ); + + return $results; + } + + /** + * Builds the HTTP(S) query + * + * @param array|string $infohashes List (>1) or string of infohash(es). + * @param string $protocol Protocol to use for the scraping. + * @param string $host Domain or IP address of the tracker. + * @param int $port Port number of the tracker, Default 80 (HTTP) or 443 (HTTPS). + * @param string $passkey Optional. Passkey provided in the scrape request. + * @return string Request query. + */ + private function http_query( $infohashes, $protocol, $host, $port, $passkey ) { + $tracker_url = $protocol . '://' . $host . ':' . $port . $passkey; + $scrape_query = ''; + + foreach ( $infohashes as $index => $infohash ) { + if ( $index > 0 ) { + $scrape_query .= '&info_hash=' . urlencode( pack( 'H*', $infohash ) ); + } else { + $scrape_query .= '/scrape?info_hash=' . urlencode( pack( 'H*', $infohash ) ); + } + } + $request_query = $tracker_url . $scrape_query; + + return $request_query; + } + + /** + * Executes the query and returns the result + * + * @throws \Exception If the connection can't be established. + * @throws \Exception If the response isn't valid. + * + * @param string $query The query that will be executed. + * @param string $host Domain or IP address of the tracker. + * @param int $port Port number of the tracker, Default 80 (HTTP) or 443 (HTTPS). + * @return string Request response. + */ + private function http_request( $query, $host, $port ) { + $context = stream_context_create( array( + 'http' => array( + 'timeout' => $this->timeout, + ), + )); + + if ( false === ( $response = @file_get_contents( $query, false, $context ) ) ) { + throw new \Exception( 'Invalid scrape connection (' . $host . ':' . $port . ').' ); + } + + if ( substr( $response, 0, 12 ) !== 'd5:filesd20:' ) { + throw new \Exception( 'Invalid scrape response (' . $host . ':' . $port . ').' ); + } + + return $response; + } + + /** + * Builds the query, sends the announce request and returns the data + * + * @throws \Exception If the connection can't be established. + * + * @param array|string $infohashes List (>1) or string of infohash(es). + * @param string $protocol Protocol to use for the scraping. + * @param string $host Domain or IP address of the tracker. + * @param int $port Port number of the tracker, Default 80 (HTTP) or 443 (HTTPS). + * @param string $passkey Optional. Passkey provided in the scrape request. + * @return string Request response. + */ + private function http_announce( $infohashes, $protocol, $host, $port, $passkey ) { + $tracker_url = $protocol . '://' . $host . ':' . $port . $passkey; + $context = stream_context_create( array( + 'http' => array( + 'timeout' => $this->timeout, + ), + )); + + $response_data = ''; + foreach ( $infohashes as $infohash ) { + $query = $tracker_url . '/announce?info_hash=' . urlencode( pack( 'H*', $infohash ) ); + if ( false === ( $response = @file_get_contents( $query, false, $context ) ) ) { + throw new \Exception( 'Invalid announce connection (' . $host . ':' . $port . ').' ); + } + + if ( substr( $response, 0, 12 ) !== 'd8:completei' || + substr( $response, 0, 46 ) === 'd8:completei0e10:downloadedi0e10:incompletei1e' ) { + continue; + } + + $ben_hash = '20:' . pack( 'H*', $infohash ) . 'd'; + $response_data .= $ben_hash . $response; + } + + return $response_data; + } + + /** + * Parses the response and returns the data + * + * @param string $response The response that will be parsed. + * @param array $infohashes List of infohash(es). + * @param string $host Domain or IP address of the tracker. + * @return array Parsed data. + */ + private function http_data( $response, $infohashes, $host ) { + $torrents_data = array(); + + foreach ( $infohashes as $infohash ) { + $ben_hash = '20:' . pack( 'H*', $infohash ) . 'd'; + $start_pos = strpos( $response, $ben_hash ); + if ( false !== $start_pos ) { + $start = $start_pos + 24; + $head = substr( $response, $start ); + $end = strpos( $head, 'ee' ) + 1; + $data = substr( $response, $start, $end ); + + $seeders = '8:completei'; + $torrent_info['seeders'] = $this->get_information( $data, $seeders, 'e' ); + + $completed = '10:downloadedi'; + $torrent_info['completed'] = $this->get_information( $data, $completed, 'e' ); + + $leechers = '10:incompletei'; + $torrent_info['leechers'] = $this->get_information( $data, $leechers, 'e' ); + + $torrents_data[ $infohash ] = $torrent_info; + } else { + $this->collect_infohash( $infohash ); + $this->errors[] = 'Invalid infohash (' . $infohash . ') for tracker: ' . $host . '.'; + } + } + + return $torrents_data; + } + + /** + * Parses a string and returns the data between $start and $end. + * + * @param string $data The data that will be parsed. + * @param string $start Beginning part of the data. + * @param string $end Ending part of the data. + * @return int Parsed information or 0. + */ + private function get_information( $data, $start, $end ) { + $start_pos = strpos( $data, $start ); + if ( false !== $start_pos ) { + $start = $start_pos + strlen( $start ); + $head = substr( $data, $start ); + $end = strpos( $head, $end ); + $information = substr( $data, $start, $end ); + + return (int) $information; + } + + return 0; + } + + /** + * Initiates the UDP scraping + * + * @param array|string $infohashes List (>1) or string of infohash(es). + * @param string $host Domain or IP address of the tracker. + * @param int $port Optional. Port number of the tracker, Default 80. + * @param bool $announce Optional. Use announce instead of scrape, Default false. + * @return array List of results. + */ + private function scrape_udp( $infohashes, $host, $port, $announce ) { + list( $socket, $transaction_id, $connection_id ) = $this->prepare_udp( $host, $port ); + + if ( true === $announce ) { + $response = $this->udp_announce( $socket, $infohashes, $connection_id ); + $keys = 'Nleechers/Nseeders'; + $start = 12; + $end = 16; + $offset = 20; + } else { + $response = $this->udp_scrape( $socket, $infohashes, $connection_id, $transaction_id, $host, $port ); + $keys = 'Nseeders/Ncompleted/Nleechers'; + $start = 8; + $end = $offset = 12; + } + $results = $this->udp_scrape_data( $response, $infohashes, $host, $keys, $start, $end, $offset ); + + return $results; + } + + /** + * Prepares the UDP connection + * + * @param string $host Domain or IP address of the tracker. + * @param int $port Optional. Port number of the tracker, Default 80. + * @return array Created socket, transaction ID and connection ID. + */ + private function prepare_udp( $host, $port ) { + $socket = $this->udp_create_connection( $host, $port ); + $transaction_id = $this->udp_connection_request( $socket ); + $connection_id = $this->udp_connection_response( $socket, $transaction_id, $host, $port ); + + return array( $socket, $transaction_id, $connection_id ); + } + + /** + * Creates the UDP socket and establishes the connection + * + * @throws \Exception If the socket couldn't be created or connected to. + * + * @param string $host Domain or IP address of the tracker. + * @param int $port Port number of the tracker, Default 80. + * @return resource $socket Created and connected socket. + */ + private function udp_create_connection( $host, $port ) { + if ( false === ( $socket = @socket_create( AF_INET, SOCK_DGRAM, SOL_UDP ) ) ) { + throw new \Exception( "Couldn't create socket." ); + } + + $timeout = $this->timeout; + socket_set_option( $socket, SOL_SOCKET, SO_RCVTIMEO, array( 'sec' => $timeout, 'usec' => 0 ) ); + socket_set_option( $socket, SOL_SOCKET, SO_SNDTIMEO, array( 'sec' => $timeout, 'usec' => 0 ) ); + if ( false === @socket_connect( $socket, $host, $port ) ) { + throw new \Exception( "Couldn't connect to socket." ); + } + + return $socket; + } + + /** + * Writes to the connected socket and returns the transaction ID + * + * @throws \Exception If the socket couldn't be written to. + * + * @param resource $socket The socket resource. + * @return int The transaction ID. + */ + private function udp_connection_request( $socket ) { + $connection_id = "\x00\x00\x04\x17\x27\x10\x19\x80"; + $action = pack( 'N', 0 ); + $transaction_id = mt_rand( 0, 2147483647 ); + $buffer = $connection_id . $action . pack( 'N', $transaction_id ); + if ( false === @socket_write( $socket, $buffer, strlen( $buffer ) ) ) { + socket_close( $socket ); + throw new \Exception( "Couldn't write to socket." ); + } + + return $transaction_id; + } + + /** + * Reads the connection response and returns the connection ID + * + * @throws \Exception If anything fails with the scraping. + * + * @param resource $socket The socket resource. + * @param int $transaction_id The transaction ID. + * @param string $host Domain or IP address of the tracker. + * @param int $port Port number of the tracker, Default 80. + * @return string The connection ID. + */ + private function udp_connection_response( $socket, $transaction_id, $host, $port ) { + if ( false === ( $response = @socket_read( $socket, 16 ) ) ) { + socket_close( $socket ); + throw new \Exception( 'Invalid scrape connection! (' . $host . ':' . $port . ').' ); + } + + if ( strlen( $response ) < 16 ) { + socket_close( $socket ); + throw new \Exception( 'Invalid scrape response (' . $host . ':' . $port . ').' ); + } + + $result = unpack( 'Naction/Ntransaction_id', $response ); + if ( 0 !== $result['action'] || $result['transaction_id'] !== $transaction_id ) { + socket_close( $socket ); + throw new \Exception( 'Invalid scrape result (' . $host . ':' . $port . ').' ); + } + + $connection_id = substr( $response, 8, 8 ); + + return $connection_id; + } + + /** + * Reads the socket response and returns the torrent data + * + * @throws \Exception If anything fails while reading the response. + * + * @param resource $socket The socket resource. + * @param array $hashes List (>1) or string of infohash(es). + * @param string $connection_id The connection ID. + * @param int $transaction_id The transaction ID. + * @param string $host Domain or IP address of the tracker. + * @param int $port Port number of the tracker, Default 80. + * @return string Response data. + */ + private function udp_scrape( $socket, $hashes, $connection_id, $transaction_id, $host, $port ) { + $this->udp_scrape_request( $socket, $hashes, $connection_id, $transaction_id ); + + $read_length = 8 + ( 12 * count( $hashes ) ); + if ( false === ( $response = @socket_read( $socket, $read_length ) ) ) { + socket_close( $socket ); + throw new \Exception( 'Invalid scrape connection (' . $host . ':' . $port . ').' ); + } + socket_close( $socket ); + + if ( strlen( $response ) < $read_length ) { + throw new \Exception( 'Invalid scrape response (' . $host . ':' . $port . ').' ); + } + + $result = unpack( 'Naction/Ntransaction_id', $response ); + if ( 2 !== $result['action'] || $result['transaction_id'] !== $transaction_id ) { + throw new \Exception( 'Invalid scrape result (' . $host . ':' . $port . ').' ); + } + + return $response; + } + + /** + * Writes to the connected socket + * + * @throws \Exception If the socket couldn't be written to. + * + * @param resource $socket The socket resource. + * @param array $hashes List (>1) or string of infohash(es). + * @param string $connection_id The connection ID. + * @param int $transaction_id The transaction ID. + */ + private function udp_scrape_request( $socket, $hashes, $connection_id, $transaction_id ) { + $action = pack( 'N', 2 ); + + $infohashes = ''; + foreach ( $hashes as $infohash ) { + $infohashes .= pack( 'H*', $infohash ); + } + + $buffer = $connection_id . $action . pack( 'N', $transaction_id ) . $infohashes; + if ( false === @socket_write( $socket, $buffer, strlen( $buffer ) ) ) { + socket_close( $socket ); + throw new \Exception( "Couldn't write to socket." ); + } + } + + /** + * Writes the announce to the connected socket + * + * @throws \Exception If the socket couldn't be written to. + * + * @param resource $socket The socket resource. + * @param array $hashes List (>1) or string of infohash(es). + * @param string $connection_id The connection ID. + * @return string Torrent(s) data. + */ + private function udp_announce( $socket, $hashes, $connection_id ) { + $action = pack( 'N', 1 ); + $downloaded = $left = $uploaded = "\x30\x30\x30\x30\x30\x30\x30\x30"; + $peer_id = $this->random_peer_id(); + $event = pack( 'N', 3 ); + $ip_addr = pack( 'N', 0 ); + $key = pack( 'N', mt_rand( 0, 2147483647 ) ); + $num_want = -1; + $ann_port = pack( 'N', mt_rand( 0, 255 ) ); + + $response_data = ''; + foreach ( $hashes as $infohash ) { + $transaction_id = mt_rand( 0, 2147483647 ); + $buffer = $connection_id . $action . pack( 'N', $transaction_id ) . pack( 'H*', $infohash ) . + $peer_id . $downloaded . $left . $uploaded . $event . $ip_addr . $key . $num_want . $ann_port; + + if ( false === @socket_write( $socket, $buffer, strlen( $buffer ) ) ) { + socket_close( $socket ); + throw new \Exception( "Couldn't write announce to socket." ); + } + + $response = $this->udp_verify_announce( $socket, $transaction_id ); + if ( false === $response ) { + continue; + } + + $response_data .= $response; + } + socket_close( $socket ); + + return $response_data; + } + + /** + * Generates a random peer ID + * + * @return string Generated peer ID. + */ + private function random_peer_id() { + $identifier = '-SP0054-'; + $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + $peer_id = $identifier . substr( str_shuffle( $chars ), 0, 12 ); + + return $peer_id; + } + + /** + * Verifies the correctness of the announce response + * + * @param resource $socket The socket resource. + * @param int $transaction_id The transaction ID. + * @return string Response data. + */ + private function udp_verify_announce( $socket, $transaction_id ) { + if ( false === ( $response = @socket_read( $socket, 20 ) ) ) { + return false; + } + + if ( strlen( $response ) < 20 ) { + return false; + } + + $result = unpack( 'Naction/Ntransaction_id', $response ); + if ( 1 !== $result['action'] || $result['transaction_id'] !== $transaction_id ) { + return false; + } + + return $response; + } + + /** + * Reads the socket response and returns the torrent data + * + * @param string $response Data from the request response. + * @param array $hashes List (>1) or string of infohash(es). + * @param string $host Domain or IP address of the tracker. + * @param string $keys Keys for the unpacked information. + * @param int $start Start of the content we want to unpack. + * @param int $end End of the content we want to unpack. + * @param int $offset Offset to the next content part. + * @return array Scraped torrent data. + */ + private function udp_scrape_data( $response, $hashes, $host, $keys, $start, $end, $offset ) { + $torrents_data = array(); + + foreach ( $hashes as $infohash ) { + $byte_string = substr( $response, $start, $end ); + $data = unpack( 'N', $byte_string ); + $content = $data[1]; + if ( ! empty( $content ) ) { + $results = unpack( $keys, $byte_string ); + $torrents_data[ $infohash ] = $results; + } else { + $this->collect_infohash( $infohash ); + $this->errors[] = 'Invalid infohash (' . $infohash . ') for tracker: ' . $host . '.'; + } + $start += $offset; + } + + return $torrents_data; + } + + /** + * Collects info-hashes that couldn't be scraped. + * + * @param string $infohash Infohash that wasn't scraped. + */ + private function collect_infohash( $infohash ) { + $this->infohashes[] = $infohash; + } + + /** + * Checks if there are any errors + * + * @return bool True or false, depending if errors are present or not. + */ + public function has_errors() { + return ! empty( $this->errors ); + } + + /** + * Returns all the errors that were logged + * + * @return array All the logged errors. + */ + public function get_errors() { + return $this->errors; + } +} diff --git a/src/public/assets/theme/default/css/framework.css b/src/public/assets/theme/default/css/framework.css index 0146324..c48f7c6 100644 --- a/src/public/assets/theme/default/css/framework.css +++ b/src/public/assets/theme/default/css/framework.css @@ -129,6 +129,10 @@ padding: 4px; } +.padding-y-4 { + padding-top: 4px; + padding-bottom: 4px; +} .padding-x-4 { padding-left: 4px; padding-right: 4px; diff --git a/src/public/index.php b/src/public/index.php index f5f8d16..36a4f3d 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -88,6 +88,17 @@ else { if ($magnet = $db->getMagnet($result->magnetid)) { + // Get access info + $accessRead = ($_SERVER['REMOTE_ADDR'] == $db->getUser($magnet->userId)->address || in_array($_SERVER['REMOTE_ADDR'], MODERATOR_IP_LIST) || ($magnet->public && $magnet->approved)); + $accessEdit = ($_SERVER['REMOTE_ADDR'] == $db->getUser($magnet->userId)->address || in_array($_SERVER['REMOTE_ADDR'], MODERATOR_IP_LIST)); + + // Update magnet viwed + if ($accessRead) + { + $db->addMagnetView($magnet->magnetId, $userId, time()); + } + + // Keywords $keywords = []; foreach ($db->findKeywordTopicByMagnetId($magnet->magnetId) as $keyword) @@ -95,6 +106,57 @@ else $keywords[] = $db->getKeywordTopic($keyword->keywordTopicId)->value; } + // Scrapes + $localScrape = (object) + [ + 'seeders' => 0, + 'completed' => 0, + 'leechers' => 0, + ]; + + $totalScrape = (object) + [ + 'seeders' => 0, + 'completed' => 0, + 'leechers' => 0, + ]; + + $trackers = []; + + foreach (TRACKER_LINKS as $tracker) + { + $trackers[] = $tracker->announce; + } + + foreach ($db->findAddressTrackerByMagnetId($magnet->magnetId) as $magnetToAddressTracker) + { + if ($addressTracker = $db->getAddressTracker($magnetToAddressTracker->addressTrackerId)) + { + $scheme = $db->getScheme($addressTracker->schemeId); + $host = $db->getHost($addressTracker->hostId); + $port = $db->getPort($addressTracker->portId); + $uri = $db->getUri($addressTracker->uriId); + + $url = $port->value ? sprintf('%s://%s:%s%s', $scheme->value, + $host->value, + $port->value, + $uri->value) : sprintf('%s://%s%s', $scheme->value, + $host->value, + $uri->value); + + if (in_array($url, $trackers)) + { + $localScrape->seeders += (int) $magnetToAddressTracker->seeders; + $localScrape->completed += (int) $magnetToAddressTracker->completed; + $localScrape->leechers += (int) $magnetToAddressTracker->leechers; + } + + $totalScrape->seeders += (int) $magnetToAddressTracker->seeders; + $totalScrape->completed += (int) $magnetToAddressTracker->completed; + $totalScrape->leechers += (int) $magnetToAddressTracker->leechers; + } + } + $response->magnets[] = (object) [ 'magnetId' => $magnet->magnetId, @@ -128,9 +190,14 @@ else ], 'access' => (object) [ - 'read' => ($_SERVER['REMOTE_ADDR'] == $db->getUser($magnet->userId)->address || in_array($_SERVER['REMOTE_ADDR'], MODERATOR_IP_LIST) || ($magnet->public && $magnet->approved)), - 'edit' => ($_SERVER['REMOTE_ADDR'] == $db->getUser($magnet->userId)->address || in_array($_SERVER['REMOTE_ADDR'], MODERATOR_IP_LIST)), + 'read' => $accessRead, + 'edit' => $accessEdit, ], + 'scrape' => (object) + [ + 'local' => $localScrape, + 'total' => $totalScrape + ] ]; } } @@ -198,8 +265,23 @@ echo '' . PHP_EOL ?> public || !$magnet->approved ? 'opacity-06 opacity-hover-1' : false ?>">
-

metaTitle ?>

+

metaTitle ?>

+ public) { ?> + + + + + + + + approved) { ?> + + + + + + access->edit) { ?> @@ -229,24 +311,41 @@ echo '' . PHP_EOL ?>
-
-
- public) { ?> - - - - - - - - approved) { ?> - - - - - - - timeUpdated ? sprintf('Updated %s', $magnet->timeUpdated) : sprintf('Added %s', $magnet->timeAdded) ?> +
+ + + + timeUpdated ? _('Updated') : _('Added') ?> + timeUpdated ? $magnet->timeUpdated : $magnet->timeAdded ?> + + + + + + + scrape->local->seeders ?> / scrape->total->seeders ?> + + + + + + scrape->local->completed ?> / scrape->total->completed ?> + + + + + + + scrape->local->leechers ?> / scrape->total->leechers ?> +
star->status) { ?>