diff --git a/plugins/rutracker_check/check.php b/plugins/rutracker_check/check.php index 117b2eea9..13be5929e 100644 --- a/plugins/rutracker_check/check.php +++ b/plugins/rutracker_check/check.php @@ -24,17 +24,24 @@ class ruTrackerChecker const STE_ERROR = 6; const STE_NOT_NEED = 7; const STE_IGNORED = 8; - + const MAX_LOCK_TIME = 900; // 15 min - + private static $TRACKERS = array(); - private static $ANNOUNCES = array(); + private static $ANNOUNCES = array(); - static public function registerTracker($commentFiler, $announceFilter, $handler) + /** + * Register a tracker handler. + * + * @param string $commentFilter Regex pattern for torrent comment + * @param string $announceFilter Regex pattern for announce URL list + * @param callable $handler Handler function: handler($url, $hash, $torrent) + */ + static public function registerTracker($commentFilter, $announceFilter, $handler) { - if(!array_key_exists($commentFiler, self::$TRACKERS)) + if(!array_key_exists($commentFilter, self::$TRACKERS)) { - self::$TRACKERS[$commentFiler] = $handler; + self::$TRACKERS[$commentFilter] = $handler; self::$ANNOUNCES[] = $announceFilter; } } @@ -46,6 +53,18 @@ static public function supportedTrackers() static protected function setState( $hash, $state ) { + // First check if the torrent still exists in rTorrent + // This prevents "info-hash not found" errors when the torrent was already + // deleted (e.g., during replacement in createTorrent) + $checkReq = new rXMLRPCRequest( new rXMLRPCCommand( getCmd("d.hash"), $hash ) ); + $checkReq->important = false; + if(!$checkReq->run() || $checkReq->fault) + { + // Torrent doesn't exist anymore, skip setting state + self::logDebug("setState: Torrent " . $hash . " not found, skipping state update"); + return(true); + } + $req = new rXMLRPCRequest( array( new rXMLRPCCommand( getCmd("d.set_custom"), array($hash, "chk-state", $state."") ), new rXMLRPCCommand( getCmd("d.set_custom"), array($hash, "chk-time", time()."") ) @@ -76,10 +95,144 @@ static protected function getState( $hash, &$state, &$time, &$successful_time, & $state = self::STE_INPROGRESS; $time = time(); $successful_time = 0; + $label = ""; return(false); } } + // Build a list of relative file paths for a torrent (single-file or multi-file). + // Used to detect renamed/missing files when swapping torrents. + static private function collectTorrentPaths($torrent) + { + if(!is_object($torrent) || !isset($torrent->info)) + return array(); + + $info = $torrent->info; + $paths = array(); + + // Multi-file mode + if(isset($info['files']) && is_array($info['files'])) + { + // Note: We do NOT prepend $info['name'] (the torrent root folder) here, + // because d.get_directory_base already returns the path INCLUDING that folder. + // If we added it, we'd get: /base/FolderName/FolderName/file.mkv (duplicate) + foreach($info['files'] as $file) + { + if(!isset($file['path']) || !is_array($file['path'])) + continue; + + // Build relative path within the torrent folder (without the folder name prefix) + $rel = implode('/', $file['path']); + // Guard against path traversal + if(strpos($rel,'..')!==false) + continue; + $paths[] = $rel; + } + } + // Single-file mode + elseif(isset($info['name'])) + { + if(strpos($info['name'],'..')===false) + $paths[] = $info['name']; + } + + // Remove possible duplicates + return array_values(array_unique($paths)); + } + + // Helper function to remove empty subdirectories recursively + static private function removeEmptySubFolders($path, $baseAbs) + { + if(empty($path) || $path == $baseAbs) + return; + + $dir = dirname($path); + // Ensure we're still inside the base directory + if(strpos(FileUtil::addslash($dir), $baseAbs) !== 0 || $dir == $baseAbs) + return; + + if(is_dir($dir)) + { + // scandir can return false (permissions, etc.), which causes TypeError in array_diff in PHP 8 + $scanned = @scandir($dir); + if(is_array($scanned)) + { + $files = array_diff($scanned, array('.', '..')); + if(empty($files)) + { + @rmdir($dir); + // Recursively go up + self::removeEmptySubFolders($dir, $baseAbs); + } + } + } + } + + // Remove files from the old torrent that are absent in the new one (to avoid duplicates after rename). + // Runs only after the new torrent is successfully loaded and the old one erased. + static private function cleanupObsoleteFiles($oldTorrent, $newTorrent, $baseDir) + { + self::logDebug("cleanupObsoleteFiles: Starting cleanup. BaseDir: " . $baseDir); + + if(empty($baseDir) || !is_object($oldTorrent) || !is_object($newTorrent)) { + self::logDebug("cleanupObsoleteFiles: Invalid arguments or objects."); + return; + } + + $oldPaths = self::collectTorrentPaths($oldTorrent); + if(empty($oldPaths)) { + self::logDebug("cleanupObsoleteFiles: No files found in old torrent."); + return; + } + + $newPaths = self::collectTorrentPaths($newTorrent); + $missing = array_diff($oldPaths, $newPaths); + + self::logDebug("cleanupObsoleteFiles: Old files count: " . count($oldPaths)); + self::logDebug("cleanupObsoleteFiles: New files count: " . count($newPaths)); + self::logDebug("cleanupObsoleteFiles: Missing files count: " . count($missing)); + + if(empty($missing)) { + self::logDebug("cleanupObsoleteFiles: No missing files to delete."); + return; + } + + $baseAbs = FileUtil::addslash(FileUtil::fullpath($baseDir)); + if(empty($baseAbs)) { + self::logDebug("cleanupObsoleteFiles: Could not resolve absolute base path."); + return; + } + self::logDebug("cleanupObsoleteFiles: Absolute base path: " . $baseAbs); + + foreach($missing as $relPath) + { + // Build an absolute path inside the data directory and ensure it doesn't escape it. + $absolute = FileUtil::fullpath($relPath, $baseAbs); + + // Security check + if(strpos(FileUtil::addslash($absolute), $baseAbs) !== 0) { + self::logDebug("cleanupObsoleteFiles: Security check failed for path: " . $absolute); + continue; + } + + if(is_file($absolute)) + { + self::logDebug("cleanupObsoleteFiles: Attempting to delete file: " . $absolute); + if(@unlink($absolute)) + { + self::logDebug("cleanupObsoleteFiles: Successfully deleted: " . $absolute); + // Try to remove parent folder if it became empty + self::removeEmptySubFolders($absolute, $baseAbs); + } else { + self::logDebug("cleanupObsoleteFiles: Failed to delete file (unlink returned false): " . $absolute); + } + } else { + self::logDebug("cleanupObsoleteFiles: File not found or not a file: " . $absolute); + } + } + self::logDebug("cleanupObsoleteFiles: Cleanup finished."); + } + static public function createTorrent($torrent, $hash){ global $saveUploadedTorrents; $torrent = new Torrent( $torrent ); @@ -87,6 +240,11 @@ static public function createTorrent($torrent, $hash){ if( $torrent->errors() ) return self::STE_DELETED; if( $torrent->hash_info()==$hash ) return self::STE_UPTODATE; + + // Keep the current torrent to compare file lists for cleanup after successful replacement. + // If loading the new torrent fails, the old files remain untouched. + $oldTorrent = rTorrent::getSource($hash); + $req = new rXMLRPCRequest( array( new rXMLRPCCommand("d.get_directory_base",$hash), new rXMLRPCCommand("d.get_custom1",$hash), @@ -100,6 +258,7 @@ static public function createTorrent($torrent, $hash){ )); if($req->success()){ + $baseDir = $req->val[0]; $addition = array( getCmd("d.set_connection_seed=").$req->val[3], getCmd("d.set_custom")."=chk-state,".self::STE_UPDATED, @@ -109,15 +268,20 @@ static public function createTorrent($torrent, $hash){ $isStart = (($req->val[4]!=0) && ($req->val[5]!=0) && ($req->val[6]!=0)); if(!empty($req->val[2])) $addition[] = getCmd("d.set_throttle_name=").$req->val[2]; - if(preg_match('/rat_(\d+)/',$req->val[3],$ratio)) - $addition[] = getCmd("view.set_visible=")."rat_".$ratio; + // Preserve ratio-group view if it was set (values like "rat_1", "rat_5" etc). + // Check if regex matched and index exists + if(preg_match('/rat_(\d+)/',$req->val[3],$ratio) && isset($ratio[1])) + $addition[] = getCmd("view.set_visible=")."rat_".$ratio[1]; $label = rawurldecode($req->val[1]); - if(rTorrent::sendTorrent($torrent, $isStart, false, $req->val[0], + if(rTorrent::sendTorrent($torrent, $isStart, false, $baseDir, $label, $saveUploadedTorrents, false, true, $addition)) { $req = new rXMLRPCRequest( new rXMLRPCCommand("d.erase", $hash ) ); - if($req->success()) + if($req->success()){ + self::cleanupObsoleteFiles($oldTorrent, $torrent, $baseDir); + // Successful .torrent replacement: new torrent state is already set via $addition return null; + } } } return self::STE_ERROR; @@ -126,24 +290,74 @@ static public function createTorrent($torrent, $hash){ static public function run_ex($hash, $fname){ $torrent = new Torrent( $fname ); if(!$torrent->errors()){ - foreach (self::$TRACKERS as $key => $value) + // Get both announce URL and comment for matching + $announce = $torrent->announce(); + $comment = $torrent->comment(); + + foreach (self::$TRACKERS as $pattern => $handler) { - if( preg_match($key, $torrent->comment()) ) + $matchedUrl = null; + + // First check comment: usually contains topic URL (e.g., viewtopic.php?t=...) + if( preg_match($pattern, $comment) ) + { + $matchedUrl = $comment; + } + // If not found in comment, try announce + elseif( preg_match($pattern, $announce) ) { - return call_user_func($value, $torrent->comment(), $hash, $torrent); + $matchedUrl = $announce; + } + + if($matchedUrl !== null) + { + return call_user_func($handler, $matchedUrl, $hash, $torrent); } } } return self::STE_NOT_NEED; } + /** + * Simple plugin logger. + * Writes to /tmp/rutracker_check.log + */ + static public function logDebug($message) + { + $logFile = '/tmp/rutracker_check.log'; + $logDir = dirname($logFile); + + // Protection: verify permissions before attempting to write + $canWrite = file_exists($logFile) ? is_writable($logFile) : is_writable($logDir); + + if($canWrite) + { + $line = '[' . gmdate('Y-m-d H:i:s') . '] ' . $message . PHP_EOL; + @file_put_contents($logFile, $line, FILE_APPEND); + } + } + static public function makeClient( $url, $method="GET", $content_type="", $body="" ) { $client = new Snoopy(); $client->read_timeout = 5; - $client->_fp_timeout = 5; - @$client->fetchComplex($url,$method,$content_type,$body); - return($client); + $client->_fp_timeout = 5; + + // Pretend to be a modern browser to reduce 403/anti-bot errors + $client->agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + . "AppleWebKit/537.36 (KHTML, like Gecko) " + . "Chrome/120.0.0.0 Safari/537.36"; + + // Suppress Snoopy errors with @, but log status on failure + @$client->fetchComplex($url, $method, $content_type, $body); + + // Convention: plugins consider status < 0 as "tracker unreachable" + if($client->status < 0) + { + self::logDebug("Snoopy fetch failed: url=".$url." status=".$client->status); + } + + return $client; } static public function run( $hash, $state = null, $time = null, $successful_time = null, $label = null ) @@ -151,8 +365,10 @@ static public function run( $hash, $state = null, $time = null, $successful_time global $ignoreLabels; if(is_null($state)) self::getState( $hash, $state, $time, $successful_time, $label ); - - if (!is_null($label) && in_array($label, $ignoreLabels)) { + + // Skip torrent if its label is in the ignore list + if(!is_null($label) && isset($ignoreLabels) && is_array($ignoreLabels) && in_array($label, $ignoreLabels)) + { $state = self::STE_IGNORED; self::setState($hash, $state); return(true); @@ -164,7 +380,16 @@ static public function run( $hash, $state = null, $time = null, $successful_time $state = self::STE_INPROGRESS; if(!self::setState( $hash, $state )) return(false); - $fname = rTorrentSettings::get()->session.$hash.".torrent"; + // Main path: via rTorrentSettings + if(class_exists('rTorrentSettings') && method_exists('rTorrentSettings', 'get')) + { + $fname = rTorrentSettings::get()->session.$hash.".torrent"; + } + else + { + // Fallback for non-standard configurations + $fname = getSettingsPath().'/session/'.$hash.".torrent"; + } if(is_readable($fname)) $state = self::run_ex($hash, $fname); if($state==self::STE_INPROGRESS) $state=self::STE_ERROR; diff --git a/plugins/rutracker_check/trackers/nnmclub.php b/plugins/rutracker_check/trackers/nnmclub.php index 094bf0c53..4f29dbbca 100644 --- a/plugins/rutracker_check/trackers/nnmclub.php +++ b/plugins/rutracker_check/trackers/nnmclub.php @@ -1,26 +1,854 @@ \d+)$`', $url, $matches)) { - $client = ruTrackerChecker::makeClient("https://nnmclub.to/forum/viewtopic.php?p=".$matches["id"]); - if ($client->status != 200) return ruTrackerChecker::STE_CANT_REACH_TRACKER; - if (preg_match('`btih:(?P[0-9A-Fa-f]{40})`', $client->results, $matches)) { - if (strtoupper($matches["hash"])==$hash) { - return ruTrackerChecker::STE_UPTODATE; + private const DEFAULT_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + . "AppleWebKit/537.36 (KHTML, like Gecko) " + . "Chrome/120.0.0.0 Safari/537.36"; + + /** + * Tracker hostnames for scrape requests (NOT behind Cloudflare). + * If the first host fails, the next is tried. + */ + private const TRACKER_HOSTS = ['bt02.nnm-club.cc', 'bt02.nnm-club.info']; + private const TRACKER_PORT = 2710; + + /** + * Default site domain for viewtopic/download requests. + * Used as fallback when the domain from comment URL is not available. + */ + private const SITE_DOMAIN = 'nnmclub.to'; + + /** + * Explicit allowlist for topic hosts to avoid requesting arbitrary domains. + */ + private const TOPIC_HOSTS = [ + 'nnmclub.ru', + 'nnmclub.me', + 'nnmclub.to', + 'nnmclub.name', + 'nnmclub.tv', + 'nnm-club.ru', + 'nnm-club.me', + 'nnm-club.to', + 'nnm-club.name', + 'nnm-club.tv', + ]; + + /** + * Regex to match dummy/guest passkeys that must be ignored. + * Guest downloads produce all-f's; all-zeros is another degenerate case. + */ + private const DUMMY_PASSKEY_RE = '/^(?:f{32}|0{32})$/i'; + + /** + * Regex fragment for NNMClub hostnames in announce URLs. + * Port is optional to support URLs with implicit default ports. + */ + private const ANNOUNCE_HOST_RE = '(?:nnm-club|nnmclub)\.\w+(?::\d+)?'; + + /** + * Regex to extract a 32-character hex passkey from an NNMClub announce URL. + * Supports both domain formats: nnm-club.* and nnmclub.*. + */ + private const TOKEN_RE = '`' . self::ANNOUNCE_HOST_RE . '/([0-9a-f]{32})/announce`i'; + + private const SCRAPE_RESULT_UPTODATE = 1; + private const SCRAPE_RESULT_NOT_FOUND = 2; + private const SCRAPE_RESULT_FAILED = 3; + + /** @var string|null Cached rtorrent session directory path */ + private static $sessionDirCache = null; + + /** @var bool True once session dir lookup has been attempted */ + private static $sessionDirCacheLoaded = false; + + /** @var bool True once donor passkey lookup has been attempted */ + private static $donorPasskeyCacheLoaded = false; + + /** @var string|null Cached donor passkey (null means not found) */ + private static $donorPasskeyCache = null; + + // ==================================================================== + // Logging + // ==================================================================== + + /** + * Log a message through ruTrackerChecker::logDebug(). + * @param string $message Message (auto-prefixed with [NNMClub]) + */ + private static function log($message) + { + ruTrackerChecker::logDebug('[NNMClub] ' . $message); + } + + /** + * Build a plain Snoopy client for "guest mode" requests. + * Intentionally uses fetch() (not fetchComplex()) to bypass loginmgr logic. + * + * @return Snoopy + */ + private static function makeGuestClient() + { + $client = new Snoopy(); + $client->read_timeout = 5; + $client->_fp_timeout = 5; + $client->agent = self::DEFAULT_USER_AGENT; + return $client; + } + + /** + * Execute a single guest HTTP request with logging for transport failures. + * + * @param Snoopy $client + * @param string $url + * @param string $method + * @param string $contentType + * @param string $body + * @return void + */ + private static function guestFetch($client, $url, $method = "GET", $contentType = "", $body = "") + { + @$client->fetch($url, $method, $contentType, $body); + if ($client->status < 0) { + self::log("Guest fetch failed: url={$url} status={$client->status}"); + } + } + + /** + * Detect common anti-bot/challenge pages where download link is absent. + * + * @param string $html + * @return bool + */ + private static function looksLikeChallengePage($html) + { + return is_string($html) + && $html !== '' + && (bool) preg_match('/cf-chl|turnstile|captcha|cloudflare|just a moment|challenge-platform/i', $html); + } + + /** + * Parse and normalize an NNMClub viewtopic URL. + * Supports both ?p=... and ?t=... topic references. + * + * @param string $url + * @return array|null ['host' => string, 'query' => string, 'id' => string] + */ + private static function parseTopicRef($url) + { + if (!is_string($url) || $url === '') return null; + + $parts = @parse_url(trim($url)); + if (!is_array($parts)) return null; + + if (isset($parts['scheme']) && !preg_match('`^https?$`i', $parts['scheme'])) { + return null; + } + + $hostOnly = isset($parts['host']) ? strtolower($parts['host']) : self::SITE_DOMAIN; + if (!self::isAllowedTopicHost($hostOnly)) { + return null; + } + $host = $hostOnly; + + if (isset($parts['port'])) { + $port = (int) $parts['port']; + if ($port < 1 || $port > 65535) return null; + $host .= ':' . $port; + } + + $path = isset($parts['path']) ? $parts['path'] : ''; + if (!preg_match('`^/forum/viewtopic\.php/?$`i', $path)) { + return null; + } + + $query = []; + parse_str(isset($parts['query']) ? $parts['query'] : '', $query); + + foreach (['p', 't'] as $param) { + if (array_key_exists($param, $query) && ctype_digit((string) $query[$param])) { + return [ + 'host' => $host, + 'query' => $param . '=' . $query[$param], + 'id' => (string) $query[$param], + ]; + } + } + + return null; + } + + /** + * Validate topic host against a strict allowlist. + * + * @param string $host + * @return bool + */ + private static function isAllowedTopicHost($host) + { + return in_array($host, self::TOPIC_HOSTS, true); + } + + // ==================================================================== + // Passkey Discovery + // ==================================================================== + + /** + * Extract a real 32-hex passkey from an announce URL or announce-list. + * Dummy/guest passkeys (all-f's, all-zeros) are filtered out. + * + * @param string|array|null $announce Single URL, announce-list array, or null + * @return string|null Lowercase 32-hex passkey, or null if not found / dummy + */ + private static function extractPasskey($announce) + { + if ($announce === null) return null; + + if (is_array($announce)) { + foreach ($announce as $tier) { + $urls = is_array($tier) ? $tier : [$tier]; + foreach ($urls as $url) { + $pk = self::extractPasskey($url); + if ($pk !== null) return $pk; } - if (preg_match('`\"download.php\?id=(?P\d+)\"`', $client->results, $matches)) { - $client->setcookies(); - $client->fetchComplex("https://nnmclub.to/forum/download.php?id=".$matches["id"]); - if ($client->status != 200) return (($client->status < 0) ? ruTrackerChecker::STE_CANT_REACH_TRACKER : ruTrackerChecker::STE_DELETED); - return ruTrackerChecker::createTorrent($client->results, $hash); + } + return null; + } + + // Accept any string (URL or raw bencode data from findDonorPasskey) + if (!is_string($announce)) return null; + + if (preg_match(self::TOKEN_RE, $announce, $m)) { + $pk = strtolower($m[1]); + if (preg_match(self::DUMMY_PASSKEY_RE, $pk)) { + return null; // Reject dummy passkey + } + return $pk; + } + return null; + } + + /** + * Scan rtorrent session directory for any NNMClub torrent with a real passkey. + * + * Since NNMClub passkeys are per-user (all interchangeable), we can + * borrow a passkey from ANY NNMClub torrent belonging to the same user. + * + * Optimization: reads only the first 4 KB for quick rejection and + * passkey extraction before falling back to full file read. + * + * @return string|null 32-hex passkey from a donor torrent, or null + */ + private static function findDonorPasskey() + { + if (self::$donorPasskeyCacheLoaded) { + self::log("Using cached donor passkey lookup result"); + return self::$donorPasskeyCache; + } + + $sessionDir = self::getSessionDir(); + if ($sessionDir === null) return self::cacheDonorPasskey(null); + + $sessionDir = rtrim($sessionDir, '/'); + if ($sessionDir === '') { + self::log("Invalid session dir from get_session, skipping donor passkey lookup"); + return self::cacheDonorPasskey(null); + } + + $files = @glob($sessionDir . '/*.torrent'); + if (!$files) { + self::log("No session torrents found for donor passkey lookup"); + return self::cacheDonorPasskey(null); + } + + foreach ($files as $path) { + // Quick rejection: check first 4 KB for NNMClub signature + $head = @file_get_contents($path, false, null, 0, 4096); + if ($head === false + || (stripos($head, 'nnm-club') === false + && stripos($head, 'nnmclub') === false)) { + continue; + } + + // Passkey is usually near the beginning of the bencoded data. + // Try head first, then full file only when needed. + $pk = self::extractPasskey($head); + if ($pk !== null) { + return self::cacheDonorPasskey($pk); + } + + if (strlen($head) >= 4096) { + $full = @file_get_contents($path); + if ($full !== false) { + $pk = self::extractPasskey($full); + if ($pk !== null) { + return self::cacheDonorPasskey($pk); + } } } } - return ruTrackerChecker::STE_NOT_NEED; + self::log("Donor passkey not found in session torrents"); + return self::cacheDonorPasskey(null); + } + + /** + * Save donor passkey lookup result in cache. + * + * @param string|null $passkey Found passkey or null if absent + * @return string|null + */ + private static function cacheDonorPasskey($passkey) + { + self::$donorPasskeyCacheLoaded = true; + self::$donorPasskeyCache = $passkey; + return $passkey; + } + + /** + * Get rtorrent session directory via XMLRPC (result is cached). + * @return string|null Session directory path, or null on failure + */ + private static function getSessionDir() + { + if (self::$sessionDirCacheLoaded) { + return self::$sessionDirCache; + } + + self::$sessionDirCacheLoaded = true; + + $req = new rXMLRPCRequest(new rXMLRPCCommand("get_session")); + if ($req->run() && !$req->fault && !empty($req->val[0])) { + self::$sessionDirCache = $req->val[0]; + return self::$sessionDirCache; + } + + self::$sessionDirCache = null; + self::log("Failed to resolve rtorrent session directory via get_session"); + return null; + } + + // ==================================================================== + // Session Torrent Patching + // ==================================================================== + + /** + * Patch announce URLs in the session .torrent file to include passkey. + * + * Uses the Torrent class for clean bencode parsing and serialization. + * Preserves libtorrent_resume and rtorrent metadata so that + * fast-resume works correctly after rtorrent restart. + * + * @param string $hash Info hash (hex, uppercase) + * @param string $passkey 32-hex passkey to inject + * @return bool True if the file was patched and saved + */ + private static function patchSessionTorrent($hash, $passkey) + { + $sessionDir = self::getSessionDir(); + if ($sessionDir === null) { + self::log("patchSessionTorrent skipped: no session dir for {$hash}"); + return false; + } + + $path = rtrim($sessionDir, '/') . '/' . $hash . '.torrent'; + if (!is_writable($path)) { + self::log("patchSessionTorrent skipped: not writable {$path}"); + return false; + } + + $torrent = new Torrent($path); + if ($torrent->errors()) { + self::log("patchSessionTorrent failed: unreadable torrent {$path}"); + return false; + } + + $changed = self::patchPasskeyInTorrent($torrent, $passkey); + if (!$changed) { + self::log("patchSessionTorrent no-op for {$hash}: no announce URLs to patch"); + return false; + } + + // NOTE: We intentionally preserve libtorrent_resume and rtorrent + // metadata — they are needed for fast-resume after rtorrent restart. + // Only the announce URLs are modified (outside the info dict), + // so the info_hash and resume data remain valid. + + $saved = (bool) $torrent->save($path); + self::log("patchSessionTorrent " . ($saved ? "saved" : "failed to save") . " for {$hash}"); + return $saved; + } + + /** + * Patch NNMClub announce URLs inside a Torrent object. + * + * @param Torrent $torrent + * @param string $passkey + * @return bool True when at least one URL was changed + */ + private static function patchPasskeyInTorrent($torrent, $passkey) + { + $announceChanged = false; + $listChanged = false; + + $announce = $torrent->announce(); + if (is_string($announce) && $announce !== '') { + $patched = self::injectPasskeyIntoUrl($announce, $passkey); + if ($patched !== $announce) { + $torrent->announce($patched); + $announceChanged = true; + } + } + + $list = $torrent->announce_list(); + if (is_array($list)) { + $newList = []; + foreach ($list as $tier) { + $newTier = []; + $urls = is_array($tier) ? $tier : [$tier]; + foreach ($urls as $url) { + $patchedUrl = is_string($url) + ? self::injectPasskeyIntoUrl($url, $passkey) + : $url; + if ($patchedUrl !== $url) { + $listChanged = true; + } + $newTier[] = $patchedUrl; + } + $newList[] = $newTier; + } + if ($listChanged) { + $torrent->announce_list($newList); + } + } + + return $announceChanged || $listChanged; + } + + /** + * Inject a passkey into an NNMClub announce URL. + * Supports both nnm-club.* and nnmclub.* domain formats. + * Non-NNMClub URLs are returned unchanged. + * + * @param string $url Announce URL + * @param string $passkey 32-hex passkey to inject + * @return string Modified URL (or original if not NNMClub) + */ + private static function injectPasskeyIntoUrl($url, $passkey) + { + $hostGroup = '(' . self::ANNOUNCE_HOST_RE . ')'; + $result = preg_replace( + '`' . $hostGroup . '/(?:[0-9a-f]{32}/)?announce`i', + '$1/' . $passkey . '/announce', + $url, + 1, + $count + ); + if ($count > 0 && $result !== null) { + return $result; + } + + return $url; + } + + // ==================================================================== + // Tracker Scrape + // ==================================================================== + + /** + * Decode one bencoded value from $data starting at $offset. + * + * @param string $data + * @param int $offset + * @return mixed + * @throws Exception + */ + private static function decodeBencodeValue($data, &$offset) + { + if (!isset($data[$offset])) { + throw new Exception("Unexpected EOF at offset {$offset}"); + } + + $token = $data[$offset]; + if ($token >= '0' && $token <= '9') { + return self::decodeBencodeString($data, $offset); + } + + if ($token === 'i') { + $offset++; + $end = strpos($data, 'e', $offset); + if ($end === false) { + throw new Exception("Invalid integer at offset {$offset}"); + } + $num = substr($data, $offset, $end - $offset); + if ($num === '' || !preg_match('/^-?\d+$/', $num)) { + throw new Exception("Invalid integer value at offset {$offset}"); + } + $offset = $end + 1; + return (int) $num; + } + + if ($token === 'l') { + $offset++; + $list = []; + while (isset($data[$offset]) && $data[$offset] !== 'e') { + $list[] = self::decodeBencodeValue($data, $offset); + } + if (!isset($data[$offset])) { + throw new Exception("Unterminated list at offset {$offset}"); + } + $offset++; + return $list; + } + + if ($token === 'd') { + $offset++; + $dict = []; + while (isset($data[$offset]) && $data[$offset] !== 'e') { + $key = self::decodeBencodeString($data, $offset); + $dict[$key] = self::decodeBencodeValue($data, $offset); + } + if (!isset($data[$offset])) { + throw new Exception("Unterminated dict at offset {$offset}"); + } + $offset++; + return $dict; + } + + throw new Exception("Unknown token '{$token}' at offset {$offset}"); + } + + /** + * Decode one bencoded byte string from $data starting at $offset. + * + * @param string $data + * @param int $offset + * @return string + * @throws Exception + */ + private static function decodeBencodeString($data, &$offset) + { + $colon = strpos($data, ':', $offset); + if ($colon === false) { + throw new Exception("Invalid string length at offset {$offset}"); + } + + $lenRaw = substr($data, $offset, $colon - $offset); + if ($lenRaw === '' || preg_match('/\D/', $lenRaw)) { + throw new Exception("Invalid string size '{$lenRaw}' at offset {$offset}"); + } + + $len = (int) $lenRaw; + $offset = $colon + 1; + if (($offset + $len) > strlen($data)) { + throw new Exception("String out of bounds at offset {$offset}"); + } + + $value = substr($data, $offset, $len); + $offset += $len; + return $value; + } + + /** + * Parse scrape bencode and check whether "files" contains $binaryHash key. + * + * @param string $payload + * @param string $binaryHash Raw 20-byte hash + * @return bool|null true: found, false: not found, null: parse error + */ + private static function scrapeContainsHash($payload, $binaryHash) + { + if (!is_string($payload) || !is_string($binaryHash) || strlen($binaryHash) !== 20) { + return false; + } + + try { + $offset = 0; + $root = self::decodeBencodeValue($payload, $offset); + } catch (Exception $e) { + self::log("Scrape parse failed: " . $e->getMessage()); + return null; + } + + return is_array($root) + && isset($root['files']) + && is_array($root['files']) + && array_key_exists($binaryHash, $root['files']); + } + + /** + * Scrape the NNMClub tracker to check if a hash is registered. + * + * Uses Snoopy (via makeClient) for consistency with the rest of the + * plugin (respects proxy settings, bind IP, timeouts). + * Tries all configured TRACKER_HOSTS with fallback on failure. + * + * @param string $passkey 32-hex passkey for tracker auth + * @param string $hash Info hash (hex, uppercase, 40 chars) + * @return int One of SCRAPE_RESULT_* constants + */ + private static function checkViaScrape($passkey, $hash) + { + $binary = @pack('H*', $hash); + if (strlen($binary) !== 20) { + self::log("Scrape skipped: invalid info hash format {$hash}"); + return self::SCRAPE_RESULT_FAILED; + } + + $sawNotFound = false; + + foreach (self::TRACKER_HOSTS as $host) { + $url = 'http://' . $host . ':' . self::TRACKER_PORT + . '/' . $passkey . '/scrape?info_hash=' . rawurlencode($binary); + + $client = ruTrackerChecker::makeClient($url); + + if ($client->status == 200 + && is_string($client->results) + && $client->results !== '') { + $hashState = self::scrapeContainsHash($client->results, $binary); + if ($hashState === true) { + return self::SCRAPE_RESULT_UPTODATE; + } + if ($hashState === null) { + self::log("Scrape response parse error on {$host}"); + continue; + } + self::log("Scrape response OK on {$host}, hash {$hash} not found"); + $sawNotFound = true; + continue; + } + self::log("Scrape failed on {$host}: status={$client->status}"); + } + + return $sawNotFound + ? self::SCRAPE_RESULT_NOT_FOUND + : self::SCRAPE_RESULT_FAILED; + } + + // ==================================================================== + // Formatting + // ==================================================================== + + /** + * Convert result values to readable log-safe strings. + * + * @param mixed $value + * @return string + */ + private static function stringify($value) + { + if ($value === null) return 'null'; + if ($value === true) return 'true'; + if ($value === false) return 'false'; + if (is_int($value) || is_float($value) || is_string($value)) { + return (string) $value; + } + return gettype($value); + } + + // ==================================================================== + // Main Entry Point + // ==================================================================== + + /** + * Check whether an NNMClub torrent needs updating. + * + * @param string $url Topic URL (viewtopic.php?p=... or ?t=...) + * @param string $hash Current info hash (hex, uppercase, 40 chars) + * @param Torrent $old_torrent Current torrent object (from session) + * @return int One of ruTrackerChecker::STE_* constants + */ + public static function download_torrent($url, $hash, $old_torrent) + { + $hash = strtoupper((string) $hash); + + // Prefer matched URL, but fallback to torrent comment if handler was + // invoked via announce URL. + $topicRef = self::parseTopicRef($url); + if ($topicRef === null && is_object($old_torrent)) { + $topicRef = self::parseTopicRef($old_torrent->comment()); + } + if ($topicRef === null) { + self::log("Skip check: unable to parse NNMClub topic reference from URL/comment"); + return ruTrackerChecker::STE_NOT_NEED; + } + $siteDomain = $topicRef['host']; + $topicQuery = $topicRef['query']; + self::log("Start check for {$hash} using {$siteDomain}/forum/viewtopic.php?{$topicQuery}"); + + // ============================================================= + // PASSKEY DISCOVERY + // ============================================================= + + $passkey = self::extractPasskey($old_torrent->announce()) + ?? self::extractPasskey($old_torrent->announce_list()); + if ($passkey !== null) { + self::log("Using passkey from current torrent metadata"); + } + + if ($passkey === null) { + $passkey = self::findDonorPasskey(); + if ($passkey !== null) { + self::log("Found donor passkey, patching session torrent {$hash}"); + if (!self::patchSessionTorrent($hash, $passkey)) { + self::log("Session patch attempt finished without file changes for {$hash}"); + } + } else { + self::log("No passkey found for {$hash}, skipping scrape"); + } + } + + // ============================================================= + // PHASE 1: TRACKER SCRAPE (fast path) + // ============================================================= + + if ($passkey !== null) { + $scrapeResult = self::checkViaScrape($passkey, $hash); + if ($scrapeResult === self::SCRAPE_RESULT_UPTODATE) { + return ruTrackerChecker::STE_UPTODATE; + } + if ($scrapeResult === self::SCRAPE_RESULT_FAILED) { + self::log("All scrape hosts failed for {$hash}"); + } + if ($scrapeResult === self::SCRAPE_RESULT_NOT_FOUND) { + self::log("Scrape did not find hash {$hash}, falling back to guest download"); + } + } + + // ============================================================= + // PHASE 2: GUEST TORRENT DOWNLOAD + // ============================================================= + + // --- Step 1: Fetch guest topic page --- + $client = self::makeGuestClient(); + self::guestFetch($client, "https://{$siteDomain}/forum/viewtopic.php?" . $topicQuery); + if ($client->status != 200) { + self::log("viewtopic fetch failed: status={$client->status}"); + return ruTrackerChecker::STE_CANT_REACH_TRACKER; + } + + // --- Step 2: btih shortcut (for authenticated sessions) --- + if (preg_match('`btih:(?P[0-9A-Fa-f]{40})`', $client->results, $btihMatch)) { + if (strtoupper($btihMatch['hash']) === $hash) { + return ruTrackerChecker::STE_UPTODATE; + } + self::log("Topic btih differs for {$topicQuery}, verifying via downloaded .torrent"); + } + + // --- Step 3: Find download link --- + if (!preg_match('`(?:/forum/)?download\.php\?id=(?P\d+)`i', $client->results, $dlMatch)) { + if (self::looksLikeChallengePage($client->results)) { + self::log("No download link for {$topicQuery}: challenge page detected"); + return ruTrackerChecker::STE_CANT_REACH_TRACKER; + } + self::log("No download link for {$topicQuery}: unexpected topic page format"); + return ruTrackerChecker::STE_ERROR; + } + $downloadId = $dlMatch['dlid']; + + // --- Step 4: Download guest .torrent --- + $client->setcookies(); + self::guestFetch($client, "https://{$siteDomain}/forum/download.php?id=" . $downloadId); + if ($client->status != 200 || empty($client->results)) { + self::log("download.php failed: status={$client->status} id={$downloadId}"); + return ($client->status < 0) + ? ruTrackerChecker::STE_CANT_REACH_TRACKER + : ruTrackerChecker::STE_ERROR; + } + + $guestData = $client->results; + + // --- Step 5: Compare info_hash --- + $guestTorrent = new Torrent($guestData); + if ($guestTorrent->errors()) { + self::log("Failed to parse downloaded torrent for {$topicQuery}"); + return ruTrackerChecker::STE_ERROR; + } + + $guestHash = strtoupper((string) $guestTorrent->hash_info()); + if ($guestHash === $hash) { + self::log("Guest torrent hash matches current hash for {$topicQuery}"); + return ruTrackerChecker::STE_UPTODATE; + } + + // --- Step 6: Hash differs → torrent was updated --- + self::log("Hash changed for {$topicQuery}: {$hash} -> {$guestHash}"); + if ($passkey === null) { + self::log("Hash differs but passkey is unavailable; refusing replacement"); + return ruTrackerChecker::STE_ERROR; + } + if (!self::patchPasskeyInTorrent($guestTorrent, $passkey)) { + self::log("Hash differs but passkey patch found no NNMClub announce URLs"); + return ruTrackerChecker::STE_ERROR; + } + + $replaceResult = ruTrackerChecker::createTorrent((string) $guestTorrent, $hash); + self::log("createTorrent result for {$hash}: " . self::stringify($replaceResult)); + return $replaceResult; } } -ruTrackerChecker::registerTracker("/(nnm-club|nnmclub)\./", "/nnm-club\./", "NNMClubCheckImpl::download_torrent"); +// Register this tracker handler with ruTrackerChecker. +// First regex: matches the torrent's comment URL. +// Second regex: matches announce URLs containing "nnm-club" or "nnmclub". +ruTrackerChecker::registerTracker( + "/(nnm-club|nnmclub)\./", + "/(nnm-club|nnmclub)\./", + "NNMClubCheckImpl::download_torrent" +); diff --git a/plugins/rutracker_check/trackers/rutracker.php b/plugins/rutracker_check/trackers/rutracker.php index 712275aee..30e31ae81 100644 --- a/plugins/rutracker_check/trackers/rutracker.php +++ b/plugins/rutracker_check/trackers/rutracker.php @@ -2,25 +2,159 @@ class RuTrackerCheckImpl { + // Decode CP1251 HTML to UTF-8 for reliable text search. + static private function decodePage($content) + { + if (!$content) return ''; + + $decoded = false; + if (function_exists('iconv')) { + $decoded = @iconv('CP1251', 'UTF-8//IGNORE', $content); + } + if (($decoded === false) && function_exists('mb_convert_encoding')) { + $decoded = @mb_convert_encoding($content, 'UTF-8', 'CP1251'); + } + return ($decoded === false || is_null($decoded)) ? $content : $decoded; + } + + // Load the last page of the topic + static private function extractLastPageHtml($client, $topic_id) + { + $topicUrl = "https://rutracker.org/forum/viewtopic.php?t=" . $topic_id; + $client->setcookies(); + $client->fetchComplex($topicUrl); + + if (($client->status != 200) || empty($client->results)) { + return null; + } + + $html = self::decodePage($client->results); + $lastStart = 0; + + // Look for pagination parameters (&start= or &start=) + if (preg_match_all('~viewtopic\.php\?t=' . $topic_id . '(?:&|&)start=(\d+)~i', $html, $startMatches) && count($startMatches[1])) { + $lastStart = max(array_map('intval', $startMatches[1])); + } + + if ($lastStart > 0) { + $client->setcookies(); + $client->fetchComplex($topicUrl . "&start=" . $lastStart); + if (($client->status == 200) && !empty($client->results)) { + $html = self::decodePage($client->results); + } + } + + return $html; + } + + // Detect new topic (if old one was absorbed) without relying on specific HTML tags + static private function detectAbsorbedTopic($client, $topic_id) + { + $html = self::extractLastPageHtml($client, $topic_id); + + if (empty($html)) return null; + + // Search for keywords "absorbed" (поглощ) or "merged" (объедин) - case-insensitive, UTF-8 + if (preg_match_all('/(поглощ|объедин)/iu', $html, $matches, PREG_OFFSET_CAPTURE)) { + + // Take the last occurrence of the keyword on the page + $lastMatch = end($matches[0]); + $keywordPos = $lastMatch[1]; + + // Search for a link in a 3000-character radius BEFORE the keyword (links often appear before the word) + $searchZoneBefore = substr($html, max(0, $keywordPos - 3000), 3000); + + // Look for the new topic ID (t=Digits) BEFORE the keyword + if (preg_match_all('/viewtopic\.php\?t=(\d+)/i', $searchZoneBefore, $linkMatches)) { + // Get the last link before the keyword (closest to it) + $lastLinkId = end($linkMatches[1]); + $newTopicId = intval($lastLinkId); + if ($newTopicId && $newTopicId != $topic_id) { + return $newTopicId; + } + } + + // If not found before, search AFTER the keyword (2000-character radius) + $searchZoneAfter = substr($html, $keywordPos, 2000); + + if (preg_match('/viewtopic\.php\?t=(\d+)/i', $searchZoneAfter, $linkMatch)) { + $newTopicId = intval($linkMatch[1]); + if ($newTopicId && $newTopicId != $topic_id) { + return $newTopicId; + } + } + } + + return null; + } + static public function download_torrent($url, $hash, $old_torrent) { if (preg_match('`^https?://rutracker\.(org|cr|net|nl)/forum/viewtopic\.php\?t=(?P\d+)$`', $url, $matches)) { $topic_id = $matches["id"]; + + // --- STAGE 1: Check via API --- $req_url = "https://api.rutracker.cc/v1/get_tor_hash?by=topic_id&val=" . $topic_id; $client = ruTrackerChecker::makeClient($req_url); - if ($client->status != 200) return ruTrackerChecker::STE_CANT_REACH_TRACKER; - $ret = json_decode($client->results, true); - if (array_key_exists("result", $ret)) $ret = $ret["result"]; - if ($ret && array_key_exists($topic_id, $ret) && (strtoupper($ret[$topic_id]) == $hash)) { - return ruTrackerChecker::STE_UPTODATE; + + if ($client->status == 200) { + $ret = @json_decode($client->results, true); + + if (is_array($ret)) { + if (array_key_exists("result", $ret)) $ret = $ret["result"]; + + // IMPORTANT: We ignore error_code == 1 (Deleted) here, + // to allow the script to manually check for absorption/relocation on the site. + + if (array_key_exists($topic_id, $ret)) { + $apiVal = $ret[$topic_id]; + // The hash can be a string or an array ['hash' => '...'] + $remoteHash = (is_array($apiVal) && isset($apiVal['hash'])) ? $apiVal['hash'] : $apiVal; + + if (!empty($hash) && strtoupper($remoteHash) == strtoupper($hash)) { + return ruTrackerChecker::STE_UPTODATE; + } + } } + } + + // --- STAGE 2: Attempt direct download --- $client->setcookies(); $client->fetchComplex("https://rutracker.org/forum/dl.php?t=" . $topic_id); - if ($client->status != 200) return (($client->status < 0) ? ruTrackerChecker::STE_CANT_REACH_TRACKER : ruTrackerChecker::STE_DELETED); - return ruTrackerChecker::createTorrent($client->results, $hash); + + // Protection against "Soft 404": server returned 200 OK, but the content is an HTML error + // Check for HTML tags (full or fragments) and error messages + $is_html_garbage = (stripos($client->results, 'results, 'results, '
') !== false) + || (stripos($client->results, 'Error:') !== false) + || (stripos($client->results, 'attachment data not found') !== false); + + if ($client->status == 200 && !$is_html_garbage) { + return ruTrackerChecker::createTorrent($client->results, $hash); + } + + // --- STAGE 3: If download failed, check for relocation (absorption) --- + + // We enter here if status != 200 OR if HTML garbage was returned + $absorbedTopicId = self::detectAbsorbedTopic($client, $topic_id); + + if (!is_null($absorbedTopicId)) { + $client->setcookies(); + // Download torrent for the NEW topic + $client->fetchComplex("https://rutracker.org/forum/dl.php?t=" . $absorbedTopicId); + + $is_new_html_garbage = (stripos($client->results, 'status == 200 && !$is_new_html_garbage) { + return ruTrackerChecker::createTorrent($client->results, $hash); + } + } + + return (($client->status < 0) ? ruTrackerChecker::STE_CANT_REACH_TRACKER : ruTrackerChecker::STE_DELETED); } return ruTrackerChecker::STE_NOT_NEED; } } -ruTrackerChecker::registerTracker("/rutracker\./", "/rutracker\.|t-ru\.org/", "RuTrackerCheckImpl::download_torrent"); +ruTrackerChecker::registerTracker("/rutracker\./", "/rutracker\.|t-ru\.org/", "RuTrackerCheckImpl::download_torrent"); \ No newline at end of file