From 3bff931186b3b6bd9b7a03ff4a39a901aff5b131 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 25 Apr 2026 09:54:59 +0300 Subject: [PATCH] Improve rutracker_check updates for RuTracker and NNMClub Rework the rutracker_check update flow so it can handle current tracker behavior without treating reachable torrents as deleted. For NNMClub, use tracker scrape as the fast path with a real passkey from the current torrent or another session torrent, then fall back to unauthenticated guest torrent downloads when the scrape does not find the current hash. Patch downloaded guest torrents with the real passkey before replacing the existing torrent, which avoids the Cloudflare-protected login flow and prevents false STE_DELETED results. For RuTracker, keep the API hash check but fall back to direct torrent download and absorbed-topic detection when the API reports a missing/deleted topic or the download endpoint returns an HTML error page instead of a .torrent file. Harden shared replacement handling by matching trackers via comment or announce URL, preserving existing start/label/throttle/ratio state, skipping state updates for hashes that no longer exist, and cleaning up obsolete files after a successful replacement when the new torrent file list differs from the old one. --- plugins/rutracker_check/check.php | 263 +++++- plugins/rutracker_check/trackers/nnmclub.php | 858 +++++++++++++++++- .../rutracker_check/trackers/rutracker.php | 150 ++- 3 files changed, 1229 insertions(+), 42 deletions(-) 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