Bearsampp 2026.9.23
Loading...
Searching...
No Matches
class.httpclient.php
Go to the documentation of this file.
1<?php
2/*
3 *
4 * * Copyright (c) 2022-2025 Bearsampp
5 * * License: GNU General Public License version 3 or later; see LICENSE.txt
6 * * Website: https://bearsampp.com
7 * * Github: https://github.com/Bearsampp
8 *
9 */
10
20{
24
35 public static function getHttpHeaders($pingUrl, $verify = true)
36 {
37 if (function_exists('curl_version')) {
38 $result = self::getCurlHttpHeaders($pingUrl, $verify);
39 } else {
40 $result = self::getFopenHttpHeaders($pingUrl, $verify);
41 }
42
43 if (!empty($result)) {
44 $rebuildResult = array();
45 foreach ($result as $row) {
46 $row = trim($row);
47 if (!empty($row)) {
48 $rebuildResult[] = $row;
49 }
50 }
51 $result = $rebuildResult;
52
53 Log::trace('getHttpHeaders: ' . count($result) . ' header(s)');
54 }
55
56 return $result;
57 }
58
70 public static function getFopenHttpHeaders($url, $verify = true)
71 {
72 $result = array();
73
74 $fp = @fopen($url, 'r', false, self::getSslStreamContext($verify));
75 if ($fp) {
76 $meta = stream_get_meta_data($fp);
77 $result = isset($meta['wrapper_data']) ? $meta['wrapper_data'] : $result;
78 fclose($fp);
79 }
80
81 return $result;
82 }
83
95 public static function getCurlHttpHeaders($url, $verify = true)
96 {
97 $result = array();
98
99 $ch = curl_init();
100 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
101 curl_setopt($ch, CURLOPT_VERBOSE, true);
102 curl_setopt($ch, CURLOPT_HEADER, true);
103 curl_setopt($ch, CURLOPT_URL, $url);
104 self::applyCurlSslOptions($ch, $verify);
105
106 $response = @curl_exec($ch);
107 if (empty($response)) {
108 return $result;
109 }
110
111 // Cap the logged response to a small prefix so headers/body never saturate
112 // the log and any transient sensitive data in the body is not written verbatim.
113 Log::trace('getCurlHttpHeaders: ' . substr($response, 0, 512));
114 $responseHeaders = explode("\r\n\r\n", $response, 2);
115 if (!isset($responseHeaders[0]) || empty($responseHeaders[0])) {
116 return $result;
117 }
118
119 return explode("\n", $responseHeaders[0]);
120 }
121
131 public static function getWebsiteUrl($path = '', $fragment = '', $utmSource = true)
132 {
133 global $bearsamppCore;
134
135 $url = APP_WEBSITE;
136 if (!empty($path)) {
137 $url .= '/' . ltrim($path, '/');
138 }
139 if ($utmSource) {
140 $url = rtrim($url, '/') . '/?utm_source=bearsampp-' . $bearsamppCore->getAppVersion();
141 }
142 if (!empty($fragment)) {
143 $url .= $fragment;
144 }
145
146 return $url;
147 }
148
157 public static function getWebsiteUrlNoUtm($path = '', $fragment = '')
158 {
159 return self::getWebsiteUrl($path, $fragment, false);
160 }
161
169 public static function getChangelogUrl($utmSource = true)
170 {
171 return self::getWebsiteUrl('doc/changelog', null, $utmSource);
172 }
173
185 public static function getGithubUrl($type = 'user', $user = APP_GITHUB_USER, $repo = null, $branch = null, $path = null)
186 {
187 if (empty($user) || !is_string($user)) {
188 return false;
189 }
190
191 // Encode as URL path segment (not query encoding)
192 $user = rawurlencode($user);
193
194 switch ($type) {
195 case 'user':
196 return "https://github.com/{$user}";
197
198 case 'repo':
199 if (empty($repo) || !is_string($repo)) {
200 return false;
201 }
202 $repo = rawurlencode($repo);
203
204 return "https://github.com/{$user}/{$repo}";
205
206 case 'issues':
207 if (empty($repo) || !is_string($repo)) {
208 return false;
209 }
210 $repo = rawurlencode($repo);
211
212 return "https://github.com/{$user}/{$repo}/issues";
213
214 case 'raw':
215 if (empty($repo) || empty($branch) || empty($path) || !is_string($repo) || !is_string($branch) || !is_string($path)) {
216 return false;
217 }
218 $repo = rawurlencode($repo);
219 $branch = rawurlencode($branch);
220
221 $path = ltrim($path, '/');
222 $segments = array_map('rawurlencode', explode('/', $path));
223 $pathEncoded = implode('/', $segments);
224
225 return "https://raw.githubusercontent.com/{$user}/{$repo}/{$branch}/{$pathEncoded}";
226
227 default:
228 return false;
229 }
230 }
231
237 public static function getGithubUserUrl()
238 {
239 return self::getGithubUrl('user', APP_GITHUB_USER);
240 }
241
256 public static function getHeaders($host, $port, $ssl = false)
257 {
258 $result = array();
259 $context = stream_context_create(array(
260 'ssl' => array(
261 'verify_peer' => false,
262 'verify_peer_name' => false,
263 'allow_self_signed' => true,
264 )
265 ));
266
267 $fp = @stream_socket_client(($ssl ? 'ssl://' : '') . $host . ':' . $port, $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $context);
268 if ($fp) {
269 $out = fgets($fp);
270 $result = explode(PHP_EOL, $out);
271 @fclose($fp);
272 }
273
274 if (!empty($result)) {
275 $rebuildResult = array();
276 foreach ($result as $row) {
277 $row = trim($row);
278 if (!empty($row)) {
279 $rebuildResult[] = $row;
280 }
281 }
282 $result = $rebuildResult;
283
284 Log::trace('getHeaders: ' . count($result) . ' header(s)');
285 }
286
287 return $result;
288 }
289
305 public static function fetchGet($url, $verify = true)
306 {
307 $ch = curl_init();
308 curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);
309 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
310 curl_setopt($ch, CURLOPT_VERBOSE, true);
311 curl_setopt($ch, CURLOPT_URL, $url);
312 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
313 'User-Agent: ' . APP_GITHUB_USERAGENT . ' (https://github.com/' . APP_GITHUB_USER . '/' . APP_GITHUB_REPO . ')',
314 'Accept: application/json, text/plain, */*',
315 ));
316 self::applyCurlSslOptions($ch, $verify);
317 $data = curl_exec($ch);
318 if (curl_errno($ch)) {
319 Log::error('CURL Error: ' . curl_error($ch));
320 }
321
322 return trim((string)$data);
323 }
324
336 public static function getApiJson($url, $verify = true)
337 {
338 Log::trace('[VCHK-3] getApiJson() sending GET request to: ' . $url);
339
340 if (self::isGithubHost($url)) {
341 $result = self::proxyFetch($url, 'GET', $verify);
342 if ($result === false || (int)$result['status'] !== 200) {
343 Log::error('GitHub request via proxy failed for: ' . $url);
344 Log::trace('[VCHK-3] getApiJson() EXIT - proxy request failed');
345
346 return '';
347 }
348
349 Log::trace('[VCHK-3] getApiJson() response length: ' . strlen((string)$result['body']));
350
351 return trim($result['body']);
352 }
353
354 $data = self::fetchGet($url, $verify);
355
356 Log::trace('[VCHK-3] getApiJson() response length: ' . strlen($data));
357
358 return $data;
359 }
360
372 public static function isGithubHost($url)
373 {
374 $host = strtolower(parse_url($url, PHP_URL_HOST) ?: '');
375
376 return in_array($host, array(
377 'github.com',
378 'api.github.com',
379 'raw.githubusercontent.com',
380 'objects.githubusercontent.com',
381 'codeload.github.com',
382 'gist.github.com',
383 ), true);
384 }
385
391 public static function getCacertPath()
392 {
393 return Path::getPhpPath() . '/extras/ssl/cacert.pem';
394 }
395
408 public static function redactUrl($url)
409 {
410 $url = (string)$url;
411 if ($url === '' || strpos($url, '?') === false) {
412 return $url;
413 }
414
415 $sensitiveParams = ['api_key', 'download_id', 'token', 'access_token', 'auth', 'password', 'passwd', 'key', 'secret'];
416
417 [$base, $query] = explode('?', $url, 2);
418 parse_str($query, $params);
419
420 foreach ($params as $name => &$value) {
421 foreach ($sensitiveParams as $sensitive) {
422 if (stripos($name, $sensitive) !== false) {
423 $value = '******';
424 break;
425 }
426 }
427 }
428
429 return $base . '?' . http_build_query($params);
430 }
431
442 private static function safeUrlForLog($url)
443 {
444 $url = (string)$url;
445 $scheme = parse_url($url, PHP_URL_SCHEME) ?: 'https';
446 $host = parse_url($url, PHP_URL_HOST) ?: '';
447 $path = parse_url($url, PHP_URL_PATH) ?: '';
448
449 if ($host === '') {
450 return '(invalid-url)';
451 }
452
453 return $scheme . '://' . $host . $path;
454 }
455
468 public static function getSslStreamContext($verify = true, $url = null)
469 {
470 $ssl = array(
471 'verify_peer' => $verify,
472 'verify_peer_name' => $verify,
473 'allow_self_signed' => !$verify,
474 );
475
476 if ($verify) {
477 $cacert = self::getCacertPath();
478 if (is_file($cacert)) {
479 $ssl['cafile'] = $cacert;
480 }
481 }
482
483 return stream_context_create(array('ssl' => $ssl));
484 }
485
497 public static function applyCurlSslOptions($ch, $verify = true)
498 {
499 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verify);
500 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verify ? 2 : 0);
501
502 if ($verify) {
503 $cacert = self::getCacertPath();
504 if (is_file($cacert)) {
505 curl_setopt($ch, CURLOPT_CAINFO, $cacert);
506 }
507 }
508 }
509
528 public static function proxyFetch($url, $method = 'GET', $verify = true)
529 {
530 if (!self::isGithubHost($url)) {
531 Log::error('[PROXY] proxyFetch() blocked non-GitHub URL: ' . self::safeUrlForLog($url));
532
533 return false;
534 }
535
536 $method = strtoupper($method);
537 Log::trace('[PROXY] proxyFetch() ' . $method . ' target: ' . self::safeUrlForLog($url));
538
539 $payload = array(
540 'url' => (string)$url,
541 'method' => $method,
542 );
543
544 $ch = curl_init();
545 curl_setopt($ch, CURLOPT_URL, APP_GITHUB_PROXY_URL);
546 curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);
547 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
548 curl_setopt($ch, CURLOPT_HEADER, true);
549 curl_setopt($ch, CURLOPT_POST, true);
550 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
551 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
552 'Content-Type: application/json',
553 'X-Bearsampp-Key: ' . APP_GITHUB_PROXY_KEY,
554 'User-Agent: ' . APP_GITHUB_USERAGENT . ' (https://github.com/' . APP_GITHUB_USER . '/' . APP_GITHUB_REPO . ')',
555 ));
556 self::applyCurlSslOptions($ch, $verify);
557
558 $response = curl_exec($ch);
559 if ($response === false) {
560 Log::error('Proxy request failed: ' . curl_error($ch));
561 Log::trace('[PROXY] proxyFetch() FAILED - target: ' . self::safeUrlForLog($url) . ' - ' . curl_error($ch));
562
563 return false;
564 }
565
566 $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
567 $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
568
569 $response = (string)$response;
570
571 $headers = array();
572 $body = '';
573
574 if ($headerSize > 0 && strlen($response) >= $headerSize) {
575 // CURLINFO_HEADER_SIZE includes every received header block, so the
576 // body is exactly the bytes after it - even when the response contains
577 // interim header sections (e.g. "100 Continue" or redirects) that make
578 // a naive "first \r\n\r\n" split corrupt.
579 $headerData = substr($response, 0, $headerSize);
580 $body = substr($response, $headerSize);
581
582 // Keep only the LAST header block (the final status line and headers).
583 $blocks = preg_split('/\r?\n\r?\n/', trim($headerData));
584 $headerBlock = end($blocks);
585
586 foreach (explode("\r\n", $headerBlock) as $line) {
587 if (strpos($line, ':') === false) {
588 continue;
589 }
590 list($name, $value) = explode(':', $line, 2);
591 // HTTP header names are case-insensitive, so normalize to a
592 // consistent lowercase key. The first occurrence of a duplicate
593 // name still wins (matching the previous behaviour).
594 $name = strtolower(trim($name));
595 $value = trim($value);
596 if ($name !== '' && !isset($headers[$name])) {
597 $headers[$name] = $value;
598 }
599 }
600 } else {
601 // Defensive fallback: no usable header size reported. Keep the whole
602 // body, but still surface any headers found after the first separator.
603 $separatorPos = strpos($response, "\r\n\r\n");
604 $body = ($separatorPos === false) ? $response : substr($response, $separatorPos + 4);
605 if ($separatorPos !== false) {
606 foreach (explode("\r\n", substr($response, 0, $separatorPos)) as $line) {
607 if (strpos($line, ':') === false) {
608 continue;
609 }
610 list($name, $value) = explode(':', $line, 2);
611 $name = strtolower(trim($name));
612 $value = trim($value);
613 if ($name !== '' && !isset($headers[$name])) {
614 $headers[$name] = $value;
615 }
616 }
617 }
618 }
619
620 Log::trace('[PROXY] proxyFetch() END - status ' . $status . ', body length: ' . strlen($body));
621
622 return array('status' => $status, 'headers' => $headers, 'body' => $body);
623 }
624
641 public static function proxyDownload($url, $filePath, $progressBar = false, $verify = true)
642 {
643 if (!self::isGithubHost($url)) {
644 Log::error('[PROXY] proxyDownload() blocked non-GitHub URL: ' . self::safeUrlForLog($url));
645
646 return false;
647 }
648
649 Log::trace('[PROXY] proxyDownload() START target: ' . self::safeUrlForLog($url) . ' -> ' . $filePath);
650
651 $outputStream = @fopen($filePath, 'wb');
652 if ($outputStream === false) {
653 Log::trace('[PROXY] proxyDownload() FAILED - cannot open output file: ' . $filePath);
654
655 return false;
656 }
657
658 $status = 0;
659 $chunksRead = 0;
660
661 $ch = curl_init();
662 curl_setopt($ch, CURLOPT_URL, APP_GITHUB_PROXY_URL);
663 curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);
664 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
665 curl_setopt($ch, CURLOPT_POST, true);
666 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array(
667 'url' => (string)$url,
668 'method' => 'GET',
669 )));
670 curl_setopt($ch, CURLOPT_HTTPHEADER, array(
671 'Content-Type: application/json',
672 'X-Bearsampp-Key: ' . APP_GITHUB_PROXY_KEY,
673 'User-Agent: ' . APP_GITHUB_USERAGENT . ' (https://github.com/' . APP_GITHUB_USER . '/' . APP_GITHUB_REPO . ')',
674 ));
675 self::applyCurlSslOptions($ch, $verify);
676
677 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::PROXY_CONNECT_TIMEOUT);
678 curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, self::PROXY_LOW_SPEED_LIMIT);
679 curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, self::PROXY_LOW_SPEED_TIME);
680
681 // Capture the status line from the response headers without writing them to the file.
682 curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$status) {
683 if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
684 $status = (int)$match[1];
685 }
686
687 return strlen($line);
688 });
689
690 // Stream the body to the output file in chunks, mirroring the legacy
691 // 8KB chunk loop and progress reporting.
692 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) use (&$outputStream, &$chunksRead, $progressBar) {
693 if ($outputStream === false) {
694 return -1; // abort the transfer
695 }
696
697 $len = fwrite($outputStream, $data);
698 if ($len === false) {
699 return -1;
700 }
701
702 $chunksRead++;
703 if ($progressBar) {
704 echo json_encode(array('progress' => $chunksRead)) . PHP_EOL;
705
706 if (ob_get_length() !== false) {
707 ob_flush();
708 }
709 flush();
710 }
711
712 return $len;
713 });
714
715 $success = (curl_exec($ch) !== false);
716 $error = curl_error($ch);
717
718 // CURLINFO_RESPONSE_CODE is the authoritative status: it works even when
719 // the raw header lines cannot be parsed (e.g. HTTP/2 ":status" pseudo
720 // headers, interim 1xx blocks, or a proxy omitting a status line), which
721 // the header callback above may silently miss. The callback capture is
722 // only kept as a fallback for the rare case cURL reports no code.
723 $curlStatus = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
724 if ($curlStatus > 0) {
725 $status = $curlStatus;
726 }
727
728 fclose($outputStream);
729
730 if (!$success) {
731 Log::error('Proxy download failed: ' . $error);
732 Log::trace('[PROXY] proxyDownload() FAILED - status ' . $status . ', target: ' . self::safeUrlForLog($url) . ' - ' . $error);
733 } else {
734 Log::trace('[PROXY] proxyDownload() END - status ' . $status . ', chunks ' . $chunksRead . ', target: ' . self::safeUrlForLog($url));
735 }
736
737 return $success && $status >= 200 && $status < 300;
738 }
739
756 public static function downloadFile(string $url, string $filePath, $progressBar = false, $verify = true)
757 {
758 // GitHub-hosted module archives are downloaded through the GitHub proxy so
759 // the client never holds or transmits a GitHub token. The body is streamed
760 // in 8KB chunks to avoid loading the whole archive into memory.
761 if (self::isGithubHost($url)) {
762 Log::trace('downloadFile() downloading via GitHub proxy: ' . $url);
763 $downloaded = self::proxyDownload($url, $filePath, $progressBar, $verify);
764 if (!$downloaded) {
765 Log::error('Error fetching content from URL: ' . $url);
766
767 return ['error' => 'Error fetching module'];
768 }
769
770 return ['success' => true];
771 }
772
773 Log::trace('downloadFile() downloading directly (non-GitHub): ' . $url);
774
775 // Open the URL for reading. The verified SSL context makes sure the file is
776 // fetched over a properly authenticated HTTPS connection.
777 $inputStream = @fopen($url, 'rb', false, self::getSslStreamContext(true, $url));
778 if ($inputStream === false) {
779 Log::error('Error fetching content from URL: ' . $url);
780
781 return ['error' => 'Error fetching module'];
782 }
783
784 // Open the file for writing
785 $outputStream = @fopen($filePath, 'wb');
786 if ($outputStream === false) {
787 Log::error('Error opening file for writing: ' . $filePath);
788 fclose($inputStream);
789
790 return ['error' => 'Error saving module'];
791 }
792
793 // Read and write in chunks to avoid memory overload
794 $bufferSize = 8096; // 8KB
795 $chunksRead = 0;
796
797 while (!feof($inputStream)) {
798 $buffer = fread($inputStream, $bufferSize);
799 fwrite($outputStream, $buffer);
800 $chunksRead++;
801
802 // Send progress update
803 if ($progressBar) {
804 $progress = $chunksRead;
805 echo json_encode(['progress' => $progress]) . PHP_EOL;
806
807 // Check if output buffering is active before calling ob_flush()
808 if (ob_get_length() !== false) {
809 ob_flush();
810 }
811 flush();
812 }
813 }
814
815 fclose($inputStream);
816 fclose($outputStream);
817
818 return ['success' => true];
819 }
820
830 public static function checkInternetState()
831 {
832 $host = str_replace(['https://', 'http://'], '', APP_WEBSITE);
833 $host = rtrim(parse_url($host, PHP_URL_HOST) ?: $host, '/');
834
835 // Verified TLS context: we only need to establish a clean TLS handshake, not
836 // fetch any content, so a successful connection is enough to confirm internet.
837 $connected = @stream_socket_client(
838 'tls://' . $host . ':443',
839 $errno,
840 $errstr,
841 5,
842 STREAM_CLIENT_CONNECT,
843 self::getSslStreamContext()
844 );
845
846 if ($connected) {
847 fclose($connected);
848
849 return true;
850 }
851
852 return false;
853 }
854}
855
$result
$port
global $bearsamppCore
static getFopenHttpHeaders($url, $verify=true)
static fetchGet($url, $verify=true)
static getWebsiteUrlNoUtm($path='', $fragment='')
static proxyFetch($url, $method='GET', $verify=true)
static getGithubUrl($type='user', $user=APP_GITHUB_USER, $repo=null, $branch=null, $path=null)
static getHeaders($host, $port, $ssl=false)
const PROXY_LOW_SPEED_TIME
static getApiJson($url, $verify=true)
const PROXY_LOW_SPEED_LIMIT
static applyCurlSslOptions($ch, $verify=true)
static safeUrlForLog($url)
static getGithubUserUrl()
static getSslStreamContext($verify=true, $url=null)
static getChangelogUrl($utmSource=true)
static redactUrl($url)
const PROXY_CONNECT_TIMEOUT
static getWebsiteUrl($path='', $fragment='', $utmSource=true)
static proxyDownload($url, $filePath, $progressBar=false, $verify=true)
static getCurlHttpHeaders($url, $verify=true)
static getHttpHeaders($pingUrl, $verify=true)
static getCacertPath()
static downloadFile(string $url, string $filePath, $progressBar=false, $verify=true)
static checkInternetState()
static isGithubHost($url)
static trace($data, $file=null)
static error($data, $file=null)
static getPhpPath($aetrayPath=false)
const APP_GITHUB_USERAGENT
Definition root.php:25
const APP_GITHUB_PROXY_URL
Definition root.php:44
const APP_WEBSITE
Definition root.php:20
const APP_GITHUB_USER
Definition root.php:23
const APP_GITHUB_REPO
Definition root.php:24
const APP_GITHUB_PROXY_KEY
Definition root.php:45