Passed
Push — master ( bc1f01...8cd5d8 )
by Pauli
03:17 queued 15s
created

HttpUtil::contextOptions()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 8
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 14
rs 10
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2022, 2023
11
 */
12
13
namespace OCA\Music\Utility;
14
15
/**
16
 * Static utility functions to work with HTTP requests
17
 */
18
class HttpUtil {
19
20
	private const ALLOWED_SCHEMES = ['http', 'https', 'feed', 'podcast', 'pcast', 'podcasts', 'itms-pcast', 'itms-pcasts', 'itms-podcast', 'itms-podcasts'];
21
22
	/**
23
	 * Use HTTP GET to load the requested URL
24
	 * @return array with three keys: ['content' => string|false, 'status_code' => int, 'message' => string, 'content_type' => string]
25
	 */
26
	public static function loadFromUrl(string $url, ?int $maxLength=null, ?int $timeout_s=null) : array {
27
		$status_code = 0;
28
		$content_type = null;
29
30
		if (!self::isUrlSchemeOneOf($url, self::ALLOWED_SCHEMES)) {
31
			$content = false;
32
			$message = 'URL scheme must be one of ' . \json_encode(self::ALLOWED_SCHEMES);
33
		} else {
34
			$context = self::createContext($timeout_s);
35
36
			// The length parameter of file_get_contents isn't nullable prior to PHP8.0
37
			if ($maxLength === null) {
38
				$content = @\file_get_contents($url, false, $context);
39
			} else {
40
				$content = @\file_get_contents($url, false, $context, 0, $maxLength);
41
			}
42
43
			// It's some PHP magic that calling file_get_contents creates and populates also a local
44
			// variable array $http_response_header, provided that the server could be reached.
45
			if (!empty($http_response_header)) {
46
				list($version, $status_code, $message) = \explode(' ', $http_response_header[0], 3);
47
				$status_code = (int)$status_code;
48
				$content_type = self::findHeader($http_response_header, 'Content-Type');
49
			} else {
50
				$message = 'The requested URL did not respond';
51
			}
52
		}
53
54
		return \compact('content', 'status_code', 'message', 'content_type');
55
	}
56
57
	/**
58
	 * @return resource
59
	 */
60
	public static function createContext(?int $timeout_s=null, array $extraHeaders = []) {
61
		$opts = self::contextOptions($extraHeaders);
62
		if ($timeout_s !== null) {
63
			$opts['http']['timeout'] = $timeout_s;
64
		}
65
		return \stream_context_create($opts);
66
	}
67
68
	/**
69
	 * @param resource $context
70
	 * @return ?array The headers from the URL, after any redirections. The header names will be array keys.
71
	 * 					In addition to the named headers from the server, the key 'status_code' will contain
72
	 * 					the status code number of the HTTP request (like 200, 302, 404).
73
	 */
74
	public static function getUrlHeaders(string $url, $context) : ?array {
75
		$result = null;
76
		if (self::isUrlSchemeOneOf($url, self::ALLOWED_SCHEMES)) {
77
			// the type of the second parameter of get_header has changed in PHP 8.0
78
			$associative = \version_compare(\phpversion(), '8.0', '<') ? 1 : true;
79
			$result = @\get_headers($url, $associative, $context);
0 ignored issues
show
Bug introduced by
It seems like $associative can also be of type integer; however, parameter $associative of get_headers() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

79
			$result = @\get_headers($url, /** @scrutinizer ignore-type */ $associative, $context);
Loading history...
80
81
			if ($result === false) {
82
				$result = null;
83
			} else {
84
				// Do some post-processing on the headers
85
				foreach ($result as $key => $value) {
86
					// Some of the headers got may be array-valued after a redirection or several, containing value
87
					// from each redirected jump. In such cases, preserve only the last value.
88
					if (\is_array($value)) {
89
						$result[$key] = \end($value);
90
					}
91
92
					// The status header like "HTTP/1.1 200 OK" can found from the index 0. If there were any redirects,
93
					// then the statuses after the redirections can be found from indices 1, 2, 3, ... That is, the status
94
					// after the last redirection can be found from the highest numerical index. We are interested about the
95
					// status code after the last redirection.
96
					if (\is_int($key)) {
97
						$result['status_code'] = (int)(\explode(' ', $value, 3)[1] ?? 500);
98
						unset($result[$key]);
99
					}
100
				}
101
			}
102
		}
103
		return $result;
104
	}
105
106
	public static function userAgentHeader() : string {
107
		return 'User-Agent: OCMusic/' . AppInfo::getVersion();
108
	}
109
110
	private static function contextOptions(array $extraHeaders = []) : array {
111
		$opts = [
112
			'http' => [
113
				'header' => self::userAgentHeader(),	// some servers don't allow requests without a user agent header
114
				'ignore_errors' => true,				// don't emit warnings for bad/unavailable URL, we handle errors manually
115
				'max_redirects' => 5
116
			]
117
		];
118
119
		foreach ($extraHeaders as $key => $value) {
120
			$opts['http']['header'] .= "\r\n$key: $value";
121
		}
122
123
		return $opts;
124
	}
125
126
	private static function findHeader(array $headers, string $headerKey) : ?string {
127
		// According to RFC 2616, HTTP headers are case-insensitive
128
		$headerKey = \mb_strtolower($headerKey);
129
		foreach ($headers as $header) {
130
			$header = \mb_strtolower($header); // note that this converts also the header value to lower case
131
			$find = \strstr($header, $headerKey . ':');
132
			if ($find !== false) {
133
				return \trim(\substr($find, \strlen($headerKey)+1));
134
			}
135
		}
136
		return null;
137
	}
138
139
	private static function isUrlSchemeOneOf(string $url, array $schemes) : bool {
140
		$url = \mb_strtolower($url);
141
142
		foreach ($schemes as $scheme) {
143
			if (Util::startsWith($url, $scheme . '://')) {
144
				return true;
145
			}
146
		}
147
148
		return false;
149
	}
150
}
151