From ee0b0ac166e68543853289bedfbd42db33d42d25 Mon Sep 17 00:00:00 2001 From: beu Date: Fri, 25 Jul 2025 21:49:05 +0200 Subject: [PATCH] fix download large file for map download --- core/Files/AsyncHttpRequest.php | 32 +++- core/Maps/DirectoryBrowser.php | 245 ++++++++++++++------------ core/Utils/WebReader.php | 3 +- libs/curl-easy/cURL/Request.php | 49 +++++- libs/curl-easy/cURL/RequestsQueue.php | 3 +- libs/curl-easy/cURL/Response.php | 24 +-- 6 files changed, 222 insertions(+), 134 deletions(-) diff --git a/core/Files/AsyncHttpRequest.php b/core/Files/AsyncHttpRequest.php index 0bd5a4df..bc5d95cc 100644 --- a/core/Files/AsyncHttpRequest.php +++ b/core/Files/AsyncHttpRequest.php @@ -36,6 +36,7 @@ class AsyncHttpRequest implements UsageInformationAble { private $contentType = 'text/xml; charset=UTF-8;'; private $timeout = 60; private $headers = array(); + private $handle = null; public function __construct($maniaControl, $url) { $this->maniaControl = $maniaControl; @@ -57,8 +58,7 @@ class AsyncHttpRequest implements UsageInformationAble { ->set(CURLOPT_USERAGENT, 'ManiaControl v' . ManiaControl::VERSION)// user-agent ->set(CURLOPT_RETURNTRANSFER, true)// ->set(CURLOPT_FOLLOWLOCATION, true)// support redirect - ->set(CURLOPT_SSL_VERIFYPEER, false) - ->set(CURLOPT_HEADER, true); + ->set(CURLOPT_SSL_VERIFYPEER, false); return $request; } @@ -76,8 +76,14 @@ class AsyncHttpRequest implements UsageInformationAble { array_push($this->headers, 'Accept-Charset: utf-8'); $request = $this->newRequest($this->url, $this->timeout); - $request->getOptions()->set(CURLOPT_AUTOREFERER, true)// accept link reference - ->set(CURLOPT_HTTPHEADER, $this->headers); // headers + $request->getOptions() + ->set(CURLOPT_AUTOREFERER, true)// accept link reference + ->set(CURLOPT_HTTPHEADER, $this->headers); // headers + + if ($this->handle !== null) { + $request->getOptions() + ->set(CURLOPT_FILE, $this->handle); + } $this->processRequest($request); } @@ -287,4 +293,22 @@ class AsyncHttpRequest implements UsageInformationAble { public function setTimeout($timeout) { $this->timeout = $timeout; } + + /** + * Gets the File Resource handle + * + * @return int + */ + public function getHandle() { + return $this->handle; + } + + /** + * Sets the File Resource handle + * + * @param int $handle + */ + public function setHandle($handle) { + $this->handle = $handle; + } } diff --git a/core/Maps/DirectoryBrowser.php b/core/Maps/DirectoryBrowser.php index d26392cf..3bf81ae2 100644 --- a/core/Maps/DirectoryBrowser.php +++ b/core/Maps/DirectoryBrowser.php @@ -629,137 +629,156 @@ class DirectoryBrowser implements ManialinkPageAnswerListener { if ($url === "") return; $folderPath = $player->getCache($this, self::CACHE_FOLDER_PATH); if (filter_var($url, FILTER_VALIDATE_URL)) { - - $asyncHttpRequest = new AsyncHttpRequest($this->maniaControl, $url); - $asyncHttpRequest->setCallable(function ($file, $error, $headers) use ($url, $folderPath, $player) { - if (!$file || $error) { - $message = "Impossible to download the file: " . $error; - $this->maniaControl->getChat()->sendError($message, $player); - Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error during the download of the zip file "'. $url .'": '. $error); - return; - } - $filePath = ""; - $finfo = new finfo(FILEINFO_MIME_TYPE); - if ($finfo->buffer($file) === "application/zip") { - $zip = new ZipArchive(); + try { + $tempFile = tempnam(sys_get_temp_dir(), 'map_'); + $fp = fopen($tempFile, 'w+'); - // Create a temporary file - $tempFile = tempnam(sys_get_temp_dir(), 'zip'); - file_put_contents($tempFile, $file); + $this->maniaControl->getChat()->sendSuccess('Starting download...', $player); - $open = $zip->open($tempFile); - - if ($open === true) { - $zip->extractTo($folderPath); - $zip->close(); - $message = "Successfully extracted zip archive from ". $url; - $this->maniaControl->getChat()->sendSuccess($message, $player); - Logger::log(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') downloaded the zip file "'. $url .'"'); - } else { - $message = "Cannot extract archive from ". $url; + $asyncHttpRequest = new AsyncHttpRequest($this->maniaControl, $url); + $asyncHttpRequest->setHandle($fp); + $asyncHttpRequest->setCallable(function ($file, $error, $headers) use ($url, $folderPath, $tempFile, $fp, $player) { + // closing file handle + fclose($fp); + if ($error) { + $message = "Impossible to download the file: " . $error; $this->maniaControl->getChat()->sendError($message, $player); - Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error when downloading the zip file "'. $url .'": Cannot extract the archive'); + Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error during the download of the zip file "'. $url .'": '. $error); + return; } - // Clean up the temporary file - unlink($tempFile); - } else { - $fileName = ""; + $filePath = ""; - $contentdispositionheader = ""; - foreach ($headers as $key => $value) { - if (strtolower($key) === "content-disposition") { - $contentdispositionheader = urldecode($value); - break; - } - } - - if ($contentdispositionheader !== "") { - $value = $contentdispositionheader; - - if (strpos($value, ';') !== false) { - - list($type, $attr_parts) = explode(';', $value, 2); + $finfo = new finfo(FILEINFO_MIME_TYPE); - $attr_parts = explode(';', $attr_parts); - $attributes = array(); - - foreach ($attr_parts as $part) { - if (strpos($part, '=') === false) { - continue; - } - - list($key, $value) = explode('=', $part, 2); - - $attributes[trim($key)] = trim($value); - } - - $fileName = null; + if ($finfo->file($tempFile) === "application/zip") { + $zip = new ZipArchive(); - if (array_key_exists('filename*', $attributes)) { - $fileName = trim($attributes['filename*']); + $open = $zip->open($tempFile); - // remove prefix if needed - if (strpos(strtolower($fileName), "utf-8''") === 0) { - $fileName = substr($fileName, strlen("utf-8''")); - } - } else if (array_key_exists('filename', $attributes)) { - $fileName = trim($attributes['filename']); - } - - if ($fileName !== null) { - if (substr($fileName, 0, 1) === '"' && substr($fileName, -1, 1) === '"') { - $fileName = substr($fileName, 1, -1); - } - - $filePath = $folderPath . FileUtil::getClearedFileName($fileName); - } - } - - if (!$this->isMapFileName($filePath)) { - $message = "File is not a map: " . $fileName; + if ($open === true) { + $zip->extractTo($folderPath); + $zip->close(); + $message = "Successfully extracted zip archive from ". $url; + $this->maniaControl->getChat()->sendSuccess($message, $player); + Logger::log(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') downloaded the zip file "'. $url .'"'); + } else { + $message = "Cannot extract archive from ". $url; $this->maniaControl->getChat()->sendError($message, $player); - Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error when downloadeding the map file "'. $fileName .'": File is not a map'); - return; + Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error when downloading the zip file "'. $url .'": Cannot extract the archive'); } + // Clean up the temporary file + unlink($tempFile); } else { - $path = parse_url($url, PHP_URL_PATH); - - // extracted basename - $fileName = basename($path); - - if (!$this->isMapFileName($fileName)) { - $fileName .= ".Map.Gbx"; - } - $filePath = $folderPath . $fileName; - } - - if ($filePath != "") { - if (file_exists($filePath)) { - $index = 1; - while (file_exists(substr($filePath, 0, -8) . "-" . $index . ".Map.Gbx")) { - $index++; + $fileName = ""; + + $contentdispositionheader = ""; + foreach ($headers as $key => $value) { + if (strtolower($key) === "content-disposition") { + $contentdispositionheader = urldecode($value); + break; } - $filePath = substr($filePath, 0, -8) . "-" . $index . ".Map.Gbx"; - } - $bytes = file_put_contents($filePath, $file); - if (!$bytes || $bytes <= 0) { - $message = "Failed to write file " . $filePath; - $this->maniaControl->getChat()->sendError($message, $player); - Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error when downloadeding the map file "'. $fileName .'": Failed to write the file'); - return; } + + if ($contentdispositionheader !== "") { + $value = $contentdispositionheader; + + if (strpos($value, ';') !== false) { + + list($type, $attr_parts) = explode(';', $value, 2); + + $attr_parts = explode(';', $attr_parts); + $attributes = array(); + + foreach ($attr_parts as $part) { + if (strpos($part, '=') === false) { + continue; + } + + list($key, $value) = explode('=', $part, 2); + + $attributes[trim($key)] = trim($value); + } + + $fileName = null; - $message = "Successfully downloaded the map ". $fileName; - $this->maniaControl->getChat()->sendSuccess($message, $player); - Logger::log(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') downloaded the map file "'. $filePath .'"'); + if (array_key_exists('filename*', $attributes)) { + $fileName = trim($attributes['filename*']); + + // remove prefix if needed + if (strpos(strtolower($fileName), "utf-8''") === 0) { + $fileName = substr($fileName, strlen("utf-8''")); + } + } else if (array_key_exists('filename', $attributes)) { + $fileName = trim($attributes['filename']); + } + + if ($fileName !== null) { + if (substr($fileName, 0, 1) === '"' && substr($fileName, -1, 1) === '"') { + $fileName = substr($fileName, 1, -1); + } + + $filePath = $folderPath . FileUtil::getClearedFileName($fileName); + } + } + + if (!$this->isMapFileName($filePath)) { + $message = "File is not a map: " . $fileName; + $this->maniaControl->getChat()->sendError($message, $player); + Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error when downloadeding the map file "'. $fileName .'": File is not a map'); + + // Clean up the temporary file + unlink($tempFile); + + return; + } + } else { + $path = parse_url($url, PHP_URL_PATH); + + // extracted basename + $fileName = basename($path); + + if (!$this->isMapFileName($fileName)) { + $fileName .= ".Map.Gbx"; + } + $filePath = $folderPath . $fileName; + } + + if ($filePath != "") { + if (file_exists($filePath)) { + $index = 1; + while (file_exists(substr($filePath, 0, -8) . "-" . $index . ".Map.Gbx")) { + $index++; + } + $filePath = substr($filePath, 0, -8) . "-" . $index . ".Map.Gbx"; + } + + $renamed = rename($tempFile, $filePath); + if (!$renamed) { + $message = "Failed to write file " . $filePath; + $this->maniaControl->getChat()->sendError($message, $player); + Logger::logError(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') encountered an error when downloadeding the map file "'. $fileName .'": Failed to write the file'); + + // Clean up the temporary file + unlink($tempFile); + + return; + } + + $message = "Successfully downloaded the map ". $fileName; + $this->maniaControl->getChat()->sendSuccess($message, $player); + Logger::log(AuthenticationManager::getAuthLevelName($player->authLevel) .' "'. $player->nickname . '" ('. $player->login .') downloaded the map file "'. $filePath .'"'); + } } - } - $this->showManiaLink($player); - }); + $this->showManiaLink($player); + }); - $asyncHttpRequest->getData(); + $asyncHttpRequest->getData(); + } catch (\Throwable $th) { + Logger::logError('Error when downloading file: '. $th->getMessage()); + $this->maniaControl->getChat()->sendError('Error when downloading file: '. $th->getMessage(), $player); + } } } } diff --git a/core/Utils/WebReader.php b/core/Utils/WebReader.php index ff76dff5..d16c75d4 100644 --- a/core/Utils/WebReader.php +++ b/core/Utils/WebReader.php @@ -47,8 +47,7 @@ abstract class WebReader { ->set(CURLOPT_USERAGENT, 'ManiaControl v' . ManiaControl::VERSION)// user-agent ->set(CURLOPT_RETURNTRANSFER, true)// return instead of output content ->set(CURLOPT_AUTOREFERER, true)// follow redirects - ->set(CURLOPT_SSL_VERIFYPEER, false) - ->set(CURLOPT_HEADER, true);; + ->set(CURLOPT_SSL_VERIFYPEER, false); return $request; } diff --git a/libs/curl-easy/cURL/Request.php b/libs/curl-easy/cURL/Request.php index 6fb263b0..3f9cf038 100644 --- a/libs/curl-easy/cURL/Request.php +++ b/libs/curl-easy/cURL/Request.php @@ -6,7 +6,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; class Request extends EventDispatcher implements RequestInterface { /** - * @var resource cURL handler + * @var \CurlHandle cURL handler */ protected $ch; @@ -19,6 +19,11 @@ class Request extends EventDispatcher implements RequestInterface * @var Options Object containing options for current request */ protected $options = null; + + /** + * @var array Response Headers feed by the CURLOPT_HEADERFUNCTION callback + */ + protected $responseHeaders; /** * Create new cURL handle @@ -30,6 +35,35 @@ class Request extends EventDispatcher implements RequestInterface if ($url !== null) { $this->getOptions()->set(CURLOPT_URL, $url); } + + $this->getOptions()->set(CURLOPT_HEADERFUNCTION, function($ch, $headerLine) { + $len = strlen($headerLine); + + // Handle HTTP status lines (e.g., HTTP/1.1 200 OK) + if (preg_match('/^HTTP\/\d\.\d\s+\d+/', $headerLine)) { + $this->responseHeaders = []; // Reset on redirect or multiple responses + } + + $parts = explode(':', $headerLine, 2); + if (count($parts) === 2) { + $key = strtolower(trim($parts[0])); + $value = trim($parts[1]); + + // Handle multiple headers with same name + if (!isset($this->responseHeaders[$key])) { + $this->responseHeaders[$key] = $value; + } else { + if (is_array($this->responseHeaders[$key])) { + $this->responseHeaders[$key][] = $value; + } else { + $this->responseHeaders[$key] = [$this->responseHeaders[$key], $value]; + } + } + } + + return $len; + }); + $this->ch = curl_init(); } @@ -72,7 +106,7 @@ class Request extends EventDispatcher implements RequestInterface /** * Returns cURL raw resource * - * @return resource cURL handle + * @return \CurlHandle cURL handle */ public function getHandle() { @@ -89,6 +123,16 @@ class Request extends EventDispatcher implements RequestInterface { return (int)$this->ch; } + + /** + * Get the response headers + * + * @return array + */ + public function getResponseHeaders() + { + return $this->responseHeaders; + } /** * Perform a cURL session. @@ -108,6 +152,7 @@ class Request extends EventDispatcher implements RequestInterface $content = curl_exec($this->ch); $response = new Response($this, $content); + $response->setHeaders($this->responseHeaders); $errorCode = curl_errno($this->ch); if ($errorCode !== CURLE_OK) { $response->setError(new Error(curl_error($this->ch), $errorCode)); diff --git a/libs/curl-easy/cURL/RequestsQueue.php b/libs/curl-easy/cURL/RequestsQueue.php index 2f9529d6..6428b0ff 100644 --- a/libs/curl-easy/cURL/RequestsQueue.php +++ b/libs/curl-easy/cURL/RequestsQueue.php @@ -11,7 +11,7 @@ class RequestsQueue extends EventDispatcher implements RequestsQueueInterface, \ protected $defaultOptions = null; /** - * @var resource cURL multi handler + * @var \CurlMultiHandle cURL multi handler */ protected $mh; @@ -115,6 +115,7 @@ class RequestsQueue extends EventDispatcher implements RequestsQueueInterface, \ $event = new Event(); $event->request = $request; $event->response = new Response($request, curl_multi_getcontent($request->getHandle())); + $event->response->setHeaders($event->request->getResponseHeaders()); if ($result !== CURLE_OK) { $event->response->setError(new Error(curl_error($request->getHandle()), $result)); } diff --git a/libs/curl-easy/cURL/Response.php b/libs/curl-easy/cURL/Response.php index 9fa296dd..da1b9dbd 100644 --- a/libs/curl-easy/cURL/Response.php +++ b/libs/curl-easy/cURL/Response.php @@ -19,18 +19,7 @@ class Response $this->ch = $request->getHandle(); if ($content != null) { - $header_size = $this->getInfo(CURLINFO_HEADER_SIZE); - - foreach (explode("\r\n", substr($content, 0, $header_size)) as $value) { - if(false !== ($matches = explode(':', $value, 2))) { - if (count($matches) === 2) { - $headers_arr["{$matches[0]}"] = trim($matches[1]); - } - } - } - $this->headers = $headers_arr; - - $this->content = substr($content, $header_size); + $this->content = $content; } } @@ -89,6 +78,17 @@ class Response return isset($this->error); } + /** + * Sets headers + * + * @param array $error headers to set + * @return void + */ + public function setHeaders(array $headers) + { + $this->headers = $headers; + } + /** * Returns headers of request *