Passed
Push — master ( c1bdec...6e9496 )
by Pauli
03:23
created

HttpUtil   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 171
Duplicated Lines 0 %

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 78
c 9
b 0
f 0
dl 0
loc 171
rs 10
wmc 29

9 Methods

Rating   Name   Duplication   Size   Complexity  
A loadFromUrl() 0 20 4
A isUrlSchemeOneOf() 0 10 3
A getUrlHeaders() 0 16 4
A userAgentHeader() 0 2 1
A contextOptions() 0 14 2
A parseHeaders() 0 29 6
A resolveRedirections() 0 19 6
A createContext() 0 6 2
A setClientCachingDays() 0 3 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
use OCP\AppFramework\Http;
16
use OCP\AppFramework\Http\Response;
17
18
/**
19
 * Static utility functions to work with HTTP requests
20
 */
21
class HttpUtil {
22
23
	private const ALLOWED_SCHEMES = ['http', 'https', 'feed', 'podcast', 'pcast', 'podcasts', 'itms-pcast', 'itms-pcasts', 'itms-podcast', 'itms-podcasts'];
24
25
	/**
26
	 * Use HTTP GET to load the requested URL
27
	 * @return array{content: string|false, status_code: int, message: string, content_type: string}
28
	 */
29
	public static function loadFromUrl(string $url, ?int $maxLength=null, ?int $timeout_s=null) : array {
30
		$context = self::createContext($timeout_s);
31
		$resolved = self::resolveRedirections($url, $context); // handles also checking for allowed URL schemes
32
33
		$status_code = $resolved['status_code'];
34
		if ($status_code >= 200 && $status_code < 300) {
35
			// The length parameter of file_get_contents isn't nullable prior to PHP8.0
36
			if ($maxLength === null) {
37
				$content = @\file_get_contents($resolved['url'], false, $context);
38
			} else {
39
				$content = @\file_get_contents($resolved['url'], false, $context, 0, $maxLength);
40
			}
41
		} else {
42
			$content = false;
43
		}
44
45
		$message = $resolved['status_msg'];
46
		$content_type = ArrayUtil::getCaseInsensitive($resolved['headers'], 'content-type');
47
48
		return \compact('content', 'status_code', 'message', 'content_type');
49
	}
50
51
	/**
52
	 * @param array<string, mixed> $extraHeaders
53
	 * @return resource
54
	 */
55
	public static function createContext(?int $timeout_s = null, array $extraHeaders = []) {
56
		$opts = self::contextOptions($extraHeaders);
57
		if ($timeout_s !== null) {
58
			$opts['http']['timeout'] = $timeout_s;
59
		}
60
		return \stream_context_create($opts);
61
	}
62
63
	/**
64
	 * Resolve redirections with a custom logic. The platform solution doesn't always work correctly, especially with
65
	 * unusually long header lines, see https://github.com/owncloud/music/issues/1209.
66
	 * @param resource $context
67
	 * @return array{url: string, status_code: int, status_msg: string, headers: array<string, string>}
68
	 * 					The final URL and the headers from the URL, after any redirections. @see HttpUtil::parseHeaders
69
	 */
70
	public static function resolveRedirections(string $url, $context, int $maxRedirects=20) : array {
71
		do {
72
			$headers = self::getUrlHeaders($url, $context);
73
			$status = $headers['status_code'];
74
			$location = ArrayUtil::getCaseInsensitive($headers['headers'], 'location');
75
			$redirect = ($status >= 300 && $status < 400 && $location !== null);
76
			if ($redirect) {
77
				if ($maxRedirects-- > 0) {
78
					$url = $location;
79
				} else {
80
					$redirect = false;
81
					$headers['status_code'] = Http::STATUS_LOOP_DETECTED;
82
					$headers['status_msg'] = 'Max number of redirections exceeded';
83
				}
84
			}
85
		} while ($redirect);
86
87
		$headers['url'] = $url;
88
		return $headers;
89
	}
90
91
	/**
92
	 * @param resource $context
93
	 * @return array{status_code: int, status_msg: string, headers: array<string, string>}
94
	 * 					The headers from the URL, after any redirections. @see HttpUtil::parseHeaders
95
	 */
96
	private static function getUrlHeaders(string $url, $context) : array {
97
		$result = null;
98
		if (self::isUrlSchemeOneOf($url, self::ALLOWED_SCHEMES)) {
99
			// the type of the second parameter of get_header has changed in PHP 8.0
100
			$associative = \version_compare(\phpversion(), '8.0', '<') ? 0 : false;
101
			$rawHeaders = @\get_headers($url, /** @scrutinizer ignore-type */ $associative, $context);
102
103
			if ($rawHeaders !== false) {
104
				$result = self::parseHeaders($rawHeaders);
105
			} else {
106
				$result = ['status_code' => Http::STATUS_SERVICE_UNAVAILABLE, 'status_msg' => 'Error connecting the URL', 'headers' => ['Content-Length' => '0']];
107
			}
108
		} else {
109
			$result = ['status_code' => Http::STATUS_FORBIDDEN, 'status_msg' => 'URL scheme not allowed', 'headers' => ['Content-Length' => '0']];
110
		}
111
		return $result;
112
	}
113
114
	/**
115
	 * @param string[] $rawHeaders
116
	 * @return array{status_code: int, status_msg: string, headers: array<string, string>}
117
	 * 			The key 'status_code' will contain the status code number of the HTTP request (like 200, 302, 404).
118
	 * 			The key 'status_msg' will contain the textual status following the code (like 'OK' or 'Not Found').
119
	 * 			The key 'headers' will contain all the named HTTP headers as a dictionary.
120
	 */
121
	private static function parseHeaders(array $rawHeaders) : array {
122
		$result = ['status_code' => 0, 'status_msg' => 'invalid', 'headers' => []];
123
124
		foreach ($rawHeaders as $row) {
125
			// The response usually starts with a header like "HTTP/1.1 200 OK". However, some shoutcast streams
126
			// may instead use "ICY 200 OK".
127
			$ignoreCase = true;
128
			if (StringUtil::startsWith($row, 'HTTP/', $ignoreCase) || StringUtil::startsWith($row, 'ICY ', $ignoreCase)) {
129
				// Start of new response. If we have already parsed some headers, then those are from some
130
				// intermediate redirect response and those should be discarded.
131
				$parts = \explode(' ', $row, 3);
132
				if (\count($parts) == 3) {
133
					list(, $status_code, $status_msg) = $parts;
134
				} else {
135
					$status_code = Http::STATUS_INTERNAL_SERVER_ERROR;
136
					$status_msg = 'Bad response status header';
137
				}
138
				$result = ['status_code' => (int)$status_code, 'status_msg' => $status_msg, 'headers' => []];
139
			} else {
140
				// All other lines besides the initial status line should have the format "key: value"
141
				$parts = \explode(':', $row, 2);
142
				if (\count($parts) == 2) {
143
					list($key, $value) = $parts;
144
					$result['headers'][\trim($key)] = \trim($value);
145
				}
146
			}
147
		}
148
149
		return $result;
150
	}
151
152
	public static function userAgentHeader() : string {
153
		return 'User-Agent: OCMusic/' . AppInfo::getVersion();
154
	}
155
156
	/**
157
	 * @param array<string, mixed> $extraHeaders
158
	 * @return array{http: array<string, mixed>}
159
	 */
160
	private static function contextOptions(array $extraHeaders = []) : array {
161
		$opts = [
162
			'http' => [
163
				'header' => self::userAgentHeader(),	// some servers don't allow requests without a user agent header
164
				'ignore_errors' => true,				// don't emit warnings for bad/unavailable URL, we handle errors manually
165
				'max_redirects' => 0					// we use our custom logic to resolve redirections
166
			]
167
		];
168
169
		foreach ($extraHeaders as $key => $value) {
170
			$opts['http']['header'] .= "\r\n$key: $value";
171
		}
172
173
		return $opts;
174
	}
175
176
	/** @param string[] $schemes */
177
	private static function isUrlSchemeOneOf(string $url, array $schemes) : bool {
178
		$url = \mb_strtolower($url);
179
180
		foreach ($schemes as $scheme) {
181
			if (StringUtil::startsWith($url, $scheme . '://')) {
182
				return true;
183
			}
184
		}
185
186
		return false;
187
	}
188
189
	public static function setClientCachingDays(Response $httpResponse, int $days) : void {
190
		$httpResponse->cacheFor($days * 24 * 60 * 60);
191
		$httpResponse->addHeader('Pragma', 'cache');
192
	}
193
}
194