Passed
Push — master ( 962923...bd302f )
by Pauli
03:04
created

HttpUtil::findHeader()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
c 0
b 0
f 0
nc 3
nop 2
dl 0
loc 11
rs 10

1 Method

Rating   Name   Duplication   Size   Complexity  
A HttpUtil::userAgentHeader() 0 2 1
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 - 2025
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
				$parsedHeaders = self::parseHeaders($http_response_header, true);
47
				$status_code = $parsedHeaders['status_code'];
48
				$message = $parsedHeaders['status_msg'];
49
				$content_type = $parsedHeaders['content-type'];
50
			} else {
51
				$message = 'The requested URL did not respond';
52
			}
53
		}
54
55
		return \compact('content', 'status_code', 'message', 'content_type');
56
	}
57
58
	/**
59
	 * @return resource
60
	 */
61
	public static function createContext(?int $timeout_s=null, array $extraHeaders = []) {
62
		$opts = self::contextOptions($extraHeaders);
63
		if ($timeout_s !== null) {
64
			$opts['http']['timeout'] = $timeout_s;
65
		}
66
		return \stream_context_create($opts);
67
	}
68
69
	/**
70
	 * @param resource $context
71
	 * @param bool $convertKeysToLower When true, the header names used as keys of the result array are
72
	 * 				converted to lower case. According to RFC 2616, HTTP headers are case-insensitive.
73
	 * @return ?array The headers from the URL, after any redirections. The header names will be array keys.
74
	 * 					In addition to the named headers from the server, the key 'status_code' will contain
75
	 * 					the status code number of the HTTP request (like 200, 302, 404) and 'status_msg'
76
	 * 					the textual status following the code (like 'OK' or 'Not Found').
77
	 */
78
	public static function getUrlHeaders(string $url, $context, bool $convertKeysToLower=false) : ?array {
79
		$result = null;
80
		if (self::isUrlSchemeOneOf($url, self::ALLOWED_SCHEMES)) {
81
			// The built-in associative mode of get_headers because it mixes up the headers from the redirection
82
			// responses with those of the last response after all the redirections, making it impossible to know,
83
			// what is the source of each header. Hence, we roll out our own parsing logic which discards all the
84
			// headers from the intermediate redirection responses.
85
86
			// the type of the second parameter of get_header has changed in PHP 8.0
87
			$associative = \version_compare(\phpversion(), '8.0', '<') ? 0 : false;
88
			$rawHeaders = @\get_headers($url, /** @scrutinizer ignore-type */ $associative, $context);
89
90
			if ($rawHeaders !== false) {
91
				$result = self::parseHeaders($rawHeaders, $convertKeysToLower);
92
			}
93
		}
94
		return $result;
95
	}
96
97
	private static function parseHeaders(array $rawHeaders, bool $convertKeysToLower) : array {
98
		$result = [];
99
100
		foreach ($rawHeaders as $row) {
101
			if (Util::startsWith($row, 'HTTP/', /*ignoreCase=*/true)) {
102
				// Start of new response. If we have already parsed some headers, then those are from some
103
				// intermediate redirect response and those should be discarded.
104
				$parts = \explode(' ', $row, 3);
105
				if (\count($parts) == 3) {
106
					list(, $status_code, $status_msg) = $parts;
107
				} else {
108
					$status_code = 500;
109
					$status_msg = 'Bad response status header';
110
				}
111
				$result = ['status_code' => (int)$status_code, 'status_msg' => $status_msg];
112
			} else {
113
				// All other lines besides the initial status line should have the format "key: value"
114
				$parts = \explode(':', $row, 2);
115
				if (\count($parts) == 2) {
116
					list($key, $value) = $parts;
117
					if ($convertKeysToLower) {
118
						$key = \mb_strtolower($key);
119
					}
120
					$result[\trim($key)] = \trim($value);
121
				}
122
			}
123
		}
124
125
		return $result;
126
	}
127
128
	public static function userAgentHeader() : string {
129
		return 'User-Agent: OCMusic/' . AppInfo::getVersion();
130
	}
131
132
	private static function contextOptions(array $extraHeaders = []) : array {
133
		$opts = [
134
			'http' => [
135
				'header' => self::userAgentHeader(),	// some servers don't allow requests without a user agent header
136
				'ignore_errors' => true,				// don't emit warnings for bad/unavailable URL, we handle errors manually
137
				'max_redirects' => 5
138
			]
139
		];
140
141
		foreach ($extraHeaders as $key => $value) {
142
			$opts['http']['header'] .= "\r\n$key: $value";
143
		}
144
145
		return $opts;
146
	}
147
148
	private static function isUrlSchemeOneOf(string $url, array $schemes) : bool {
149
		$url = \mb_strtolower($url);
150
151
		foreach ($schemes as $scheme) {
152
			if (Util::startsWith($url, $scheme . '://')) {
153
				return true;
154
			}
155
		}
156
157
		return false;
158
	}
159
}
160