Passed
Push — master ( 23afda...b1ae99 )
by Pauli
03:08
created

HttpUtil::setClientCachingDays()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 3
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 - 2025
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use OCP\AppFramework\Http\Response;
16
17
/**
18
 * Static utility functions to work with HTTP requests
19
 */
20
class HttpUtil {
21
22
	private const ALLOWED_SCHEMES = ['http', 'https', 'feed', 'podcast', 'pcast', 'podcasts', 'itms-pcast', 'itms-pcasts', 'itms-podcast', 'itms-podcasts'];
23
24
	/**
25
	 * Use HTTP GET to load the requested URL
26
	 * @return array with three keys: ['content' => string|false, 'status_code' => int, 'message' => string, 'content_type' => string]
27
	 */
28
	public static function loadFromUrl(string $url, ?int $maxLength=null, ?int $timeout_s=null) : array {
29
		$status_code = 0;
30
		$content_type = null;
31
32
		if (!self::isUrlSchemeOneOf($url, self::ALLOWED_SCHEMES)) {
33
			$content = false;
34
			$message = 'URL scheme must be one of ' . \json_encode(self::ALLOWED_SCHEMES);
35
		} else {
36
			$context = self::createContext($timeout_s);
37
38
			// The length parameter of file_get_contents isn't nullable prior to PHP8.0
39
			if ($maxLength === null) {
40
				$content = @\file_get_contents($url, false, $context);
41
			} else {
42
				$content = @\file_get_contents($url, false, $context, 0, $maxLength);
43
			}
44
45
			// It's some PHP magic that calling file_get_contents creates and populates also a local
46
			// variable array $http_response_header, provided that the server could be reached.
47
			if (!empty($http_response_header)) {
48
				$parsedHeaders = self::parseHeaders($http_response_header, true);
49
				$status_code = $parsedHeaders['status_code'];
50
				$message = $parsedHeaders['status_msg'];
51
				$content_type = $parsedHeaders['content-type'];
52
			} else {
53
				$message = 'The requested URL did not respond';
54
			}
55
		}
56
57
		return \compact('content', 'status_code', 'message', 'content_type');
58
	}
59
60
	/**
61
	 * @return resource
62
	 */
63
	public static function createContext(?int $timeout_s=null, array $extraHeaders = []) {
64
		$opts = self::contextOptions($extraHeaders);
65
		if ($timeout_s !== null) {
66
			$opts['http']['timeout'] = $timeout_s;
67
		}
68
		return \stream_context_create($opts);
69
	}
70
71
	/**
72
	 * @param resource $context
73
	 * @param bool $convertKeysToLower When true, the header names used as keys of the result array are
74
	 * 				converted to lower case. According to RFC 2616, HTTP headers are case-insensitive.
75
	 * @return ?array The headers from the URL, after any redirections. The header names will be array keys.
76
	 * 					In addition to the named headers from the server, the key 'status_code' will contain
77
	 * 					the status code number of the HTTP request (like 200, 302, 404) and 'status_msg'
78
	 * 					the textual status following the code (like 'OK' or 'Not Found').
79
	 */
80
	public static function getUrlHeaders(string $url, $context, bool $convertKeysToLower=false) : ?array {
81
		$result = null;
82
		if (self::isUrlSchemeOneOf($url, self::ALLOWED_SCHEMES)) {
83
			// The built-in associative mode of get_headers because it mixes up the headers from the redirection
84
			// responses with those of the last response after all the redirections, making it impossible to know,
85
			// what is the source of each header. Hence, we roll out our own parsing logic which discards all the
86
			// headers from the intermediate redirection responses.
87
88
			// the type of the second parameter of get_header has changed in PHP 8.0
89
			$associative = \version_compare(\phpversion(), '8.0', '<') ? 0 : false;
90
			$rawHeaders = @\get_headers($url, /** @scrutinizer ignore-type */ $associative, $context);
91
92
			if ($rawHeaders !== false) {
93
				$result = self::parseHeaders($rawHeaders, $convertKeysToLower);
94
			}
95
		}
96
		return $result;
97
	}
98
99
	private static function parseHeaders(array $rawHeaders, bool $convertKeysToLower) : array {
100
		$result = [];
101
102
		foreach ($rawHeaders as $row) {
103
			if (Util::startsWith($row, 'HTTP/', /*ignoreCase=*/true)) {
104
				// Start of new response. If we have already parsed some headers, then those are from some
105
				// intermediate redirect response and those should be discarded.
106
				$parts = \explode(' ', $row, 3);
107
				if (\count($parts) == 3) {
108
					list(, $status_code, $status_msg) = $parts;
109
				} else {
110
					$status_code = 500;
111
					$status_msg = 'Bad response status header';
112
				}
113
				$result = ['status_code' => (int)$status_code, 'status_msg' => $status_msg];
114
			} else {
115
				// All other lines besides the initial status line should have the format "key: value"
116
				$parts = \explode(':', $row, 2);
117
				if (\count($parts) == 2) {
118
					list($key, $value) = $parts;
119
					if ($convertKeysToLower) {
120
						$key = \mb_strtolower($key);
121
					}
122
					$result[\trim($key)] = \trim($value);
123
				}
124
			}
125
		}
126
127
		return $result;
128
	}
129
130
	public static function userAgentHeader() : string {
131
		return 'User-Agent: OCMusic/' . AppInfo::getVersion();
132
	}
133
134
	private static function contextOptions(array $extraHeaders = []) : array {
135
		$opts = [
136
			'http' => [
137
				'header' => self::userAgentHeader(),	// some servers don't allow requests without a user agent header
138
				'ignore_errors' => true,				// don't emit warnings for bad/unavailable URL, we handle errors manually
139
				'max_redirects' => 5
140
			]
141
		];
142
143
		foreach ($extraHeaders as $key => $value) {
144
			$opts['http']['header'] .= "\r\n$key: $value";
145
		}
146
147
		return $opts;
148
	}
149
150
	private static function isUrlSchemeOneOf(string $url, array $schemes) : bool {
151
		$url = \mb_strtolower($url);
152
153
		foreach ($schemes as $scheme) {
154
			if (Util::startsWith($url, $scheme . '://')) {
155
				return true;
156
			}
157
		}
158
159
		return false;
160
	}
161
162
	public static function setClientCachingDays(Response &$httpResponse, int $days) : void {
163
		$httpResponse->cacheFor($days * 24 * 60 * 60);
164
		$httpResponse->addHeader('Pragma', 'cache');
165
	}
166
}
167