summaryrefslogtreecommitdiff
path: root/lib/contents.php
blob: 8649c0b6dbf784b0fdebc48e546ba18a8ef4b3aa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
<?php
/**
 * This file is part of RSS-Bridge, a PHP project capable of generating RSS and
 * Atom feeds for websites that don't have one.
 *
 * For the full license information, please view the UNLICENSE file distributed
 * with this source code.
 *
 * @package	Core
 * @license	http://unlicense.org/ UNLICENSE
 * @link	https://github.com/rss-bridge/rss-bridge
 */

/**
 * Gets contents from the Internet.
 *
 * **Content caching** (disabled in debug mode)
 *
 * A copy of the received content is stored in a local cache folder `server/` at
 * {@see PATH_CACHE}. The `If-Modified-Since` header is added to the request, if
 * the provided URL has been cached before.
 *
 * When the server responds with `304 Not Modified`, the cached data is returned.
 * This will improve response times and reduce bandwidth for servers that support
 * the `If-Modified-Since` header.
 *
 * Cached files are forcefully removed after 24 hours.
 *
 * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
 * If-Modified-Since
 *
 * @param string $url The URL.
 * @param array $header (optional) A list of cURL header.
 * For more information follow the links below.
 * * https://php.net/manual/en/function.curl-setopt.php
 * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html
 * @param array $opts (optional) A list of cURL options as associative array in
 * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
 * option and `$value` the corresponding value.
 *
 * For more information see http://php.net/manual/en/function.curl-setopt.php
 * @return string The contents.
 */
function getContents($url, $header = array(), $opts = array()){
	Debug::log('Reading contents from "' . $url . '"');

	// Initialize cache
	$cacheFac = new CacheFactory();
	$cacheFac->setWorkingDir(PATH_LIB_CACHES);
	$cache = $cacheFac->create(Configuration::getConfig('cache', 'type'));
	$cache->setScope('server');
	$cache->purgeCache(86400); // 24 hours (forced)

	$params = [$url];
	$cache->setKey($params);

	// Use file_get_contents if in CLI mode with no root certificates defined
	if(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo'))) {

		$httpHeaders = '';

		foreach ($header as $headerL) {
			$httpHeaders .= $headerL . "\r\n";
		}

		$ctx = stream_context_create(array(
			'http' => array(
				'header' => $httpHeaders
			)
		));

		$data = @file_get_contents($url, 0, $ctx);

		if($data === false) {
			$errorCode = 500;
		} else {
			$errorCode = 200;
		}

		$curlError = '';
		$curlErrno = '';
		$headerSize = 0;
		$finalHeader = array();
	} else {
		$ch = curl_init($url);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
		curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

		if(is_array($header) && count($header) !== 0) {

			Debug::log('Setting headers: ' . json_encode($header));
			curl_setopt($ch, CURLOPT_HTTPHEADER, $header);

		}

		curl_setopt($ch, CURLOPT_USERAGENT, ini_get('user_agent'));
		curl_setopt($ch, CURLOPT_ENCODING, '');
		curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);

		if(is_array($opts) && count($opts) !== 0) {

			Debug::log('Setting options: ' . json_encode($opts));

			foreach($opts as $key => $value) {
				curl_setopt($ch, $key, $value);
			}

		}

		if(defined('PROXY_URL') && !defined('NOPROXY')) {

			Debug::log('Setting proxy url: ' . PROXY_URL);
			curl_setopt($ch, CURLOPT_PROXY, PROXY_URL);

		}

		// We always want the response header as part of the data!
		curl_setopt($ch, CURLOPT_HEADER, true);

		// Build "If-Modified-Since" header
		if(!Debug::isEnabled() && $time = $cache->getTime()) { // Skip if cache file doesn't exist
			Debug::log('Adding If-Modified-Since');
			curl_setopt($ch, CURLOPT_TIMEVALUE, $time);
			curl_setopt($ch, CURLOPT_TIMECONDITION, CURL_TIMECOND_IFMODSINCE);
		}

		// Enables logging for the outgoing header
		curl_setopt($ch, CURLINFO_HEADER_OUT, true);

		$data = curl_exec($ch);
		$errorCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

		$curlError = curl_error($ch);
		$curlErrno = curl_errno($ch);
		$curlInfo = curl_getinfo($ch);

		Debug::log('Outgoing header: ' . json_encode($curlInfo));

		if($data === false)
			Debug::log('Cant\'t download ' . $url . ' cUrl error: ' . $curlError . ' (' . $curlErrno . ')');

		$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
		$header = substr($data, 0, $headerSize);

		Debug::log('Response header: ' . $header);

		$headers = parseResponseHeader($header);
		$finalHeader = end($headers);

		curl_close($ch);
	}

	switch($errorCode) {
		case 200: // Contents received
			Debug::log('New contents received');
			$data = substr($data, $headerSize);
			// Disable caching if the server responds with "Cache-Control: no-cache"
			// or "Cache-Control: no-store"
			$finalHeader = array_change_key_case($finalHeader, CASE_LOWER);
			if(array_key_exists('cache-control', $finalHeader)) {
				Debug::log('Server responded with "Cache-Control" header');
				$directives = explode(',', $finalHeader['cache-control']);
				$directives = array_map('trim', $directives);
				if(in_array('no-cache', $directives)
				|| in_array('no-store', $directives)) { // Skip caching
					Debug::log('Skip server side caching');
					return $data;
				}
			}
			Debug::log('Store response to cache');
			$cache->saveData($data);
			return $data;
		case 304: // Not modified, use cached data
			Debug::log('Contents not modified on host, returning cached data');
			return $cache->loadData();
		default:
			if(array_key_exists('Server', $finalHeader) && strpos($finalHeader['Server'], 'cloudflare') !== false) {
			returnServerError(<<< EOD
The server responded with a Cloudflare challenge, which is not supported by RSS-Bridge!
If this error persists longer than a week, please consider opening an issue on GitHub!
EOD
				);
			}

			$lastError = error_get_last();
			if($lastError !== null)
				$lastError = $lastError['message'];
			returnError(<<<EOD
The requested resource cannot be found!
Please make sure your input parameters are correct!
cUrl error: $curlError ($curlErrno)
PHP error: $lastError
EOD
			, $errorCode);
	}
}

/**
 * Gets contents from the Internet as simplhtmldom object.
 *
 * @param string $url The URL.
 * @param array $header (optional) A list of cURL header.
 * For more information follow the links below.
 * * https://php.net/manual/en/function.curl-setopt.php
 * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html
 * @param array $opts (optional) A list of cURL options as associative array in
 * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
 * option and `$value` the corresponding value.
 *
 * For more information see http://php.net/manual/en/function.curl-setopt.php
 * @param bool $lowercase Force all selectors to lowercase.
 * @param bool $forceTagsClosed Forcefully close tags in malformed HTML.
 *
 * _Remarks_: Forcefully closing tags is great for malformed HTML, but it can
 * lead to parsing errors.
 * @param string $target_charset Defines the target charset.
 * @param bool $stripRN Replace all occurrences of `"\r"` and `"\n"` by `" "`.
 * @param string $defaultBRText Specifies the replacement text for `<br>` tags
 * when returning plaintext.
 * @param string $defaultSpanText Specifies the replacement text for `<span />`
 * tags when returning plaintext.
 * @return string Contents as simplehtmldom object.
 */
function getSimpleHTMLDOM($url,
	$header = array(),
	$opts = array(),
	$lowercase = true,
	$forceTagsClosed = true,
	$target_charset = DEFAULT_TARGET_CHARSET,
	$stripRN = true,
	$defaultBRText = DEFAULT_BR_TEXT,
	$defaultSpanText = DEFAULT_SPAN_TEXT){

	$content = getContents($url, $header, $opts);
	return str_get_html($content,
	$lowercase,
	$forceTagsClosed,
	$target_charset,
	$stripRN,
	$defaultBRText,
	$defaultSpanText);
}

/**
 * Gets contents from the Internet as simplhtmldom object. Contents are cached
 * and re-used for subsequent calls until the cache duration elapsed.
 *
 * _Notice_: Cached contents are forcefully removed after 24 hours (86400 seconds).
 *
 * @param string $url The URL.
 * @param int $duration Cache duration in seconds.
 * @param array $header (optional) A list of cURL header.
 * For more information follow the links below.
 * * https://php.net/manual/en/function.curl-setopt.php
 * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html
 * @param array $opts (optional) A list of cURL options as associative array in
 * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`
 * option and `$value` the corresponding value.
 *
 * For more information see http://php.net/manual/en/function.curl-setopt.php
 * @param bool $lowercase Force all selectors to lowercase.
 * @param bool $forceTagsClosed Forcefully close tags in malformed HTML.
 *
 * _Remarks_: Forcefully closing tags is great for malformed HTML, but it can
 * lead to parsing errors.
 * @param string $target_charset Defines the target charset.
 * @param bool $stripRN Replace all occurrences of `"\r"` and `"\n"` by `" "`.
 * @param string $defaultBRText Specifies the replacement text for `<br>` tags
 * when returning plaintext.
 * @param string $defaultSpanText Specifies the replacement text for `<span />`
 * tags when returning plaintext.
 * @return string Contents as simplehtmldom object.
 */
function getSimpleHTMLDOMCached($url,
	$duration = 86400,
	$header = array(),
	$opts = array(),
	$lowercase = true,
	$forceTagsClosed = true,
	$target_charset = DEFAULT_TARGET_CHARSET,
	$stripRN = true,
	$defaultBRText = DEFAULT_BR_TEXT,
	$defaultSpanText = DEFAULT_SPAN_TEXT){

	Debug::log('Caching url ' . $url . ', duration ' . $duration);

	// Initialize cache
	$cacheFac = new CacheFactory();
	$cacheFac->setWorkingDir(PATH_LIB_CACHES);
	$cache = $cacheFac->create(Configuration::getConfig('cache', 'type'));
	$cache->setScope('pages');
	$cache->purgeCache(86400); // 24 hours (forced)

	$params = [$url];
	$cache->setKey($params);

	// Determine if cached file is within duration
	$time = $cache->getTime();
	if($time !== false
	&& (time() - $duration < $time)
	&& Debug::isEnabled()) { // Contents within duration
		$content = $cache->loadData();
	} else { // Content not within duration
		$content = getContents($url, $header, $opts);
		if($content !== false) {
			$cache->saveData($content);
		}
	}

	return str_get_html($content,
	$lowercase,
	$forceTagsClosed,
	$target_charset,
	$stripRN,
	$defaultBRText,
	$defaultSpanText);
}

/**
 * Parses the cURL response header into an associative array
 *
 * Based on https://stackoverflow.com/a/18682872
 *
 * @param string $header The cURL response header.
 * @return array An associative array of response headers.
 */
function parseResponseHeader($header) {

	$headers = array();
	$requests = explode("\r\n\r\n", trim($header));

	foreach ($requests as $request) {

		$header = array();

		foreach (explode("\r\n", $request) as $i => $line) {

			if($i === 0) {
				$header['http_code'] = $line;
			} else {

				list ($key, $value) = explode(':', $line);
				$header[$key] = trim($value);

			}

		}

		$headers[] = $header;

	}

	return $headers;

}

/**
 * Determines the MIME type from a URL/Path file extension.
 *
 * _Remarks_:
 *
 * * The built-in functions `mime_content_type` and `fileinfo` require fetching
 * remote contents.
 * * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`).
 *
 * Based on https://stackoverflow.com/a/1147952
 *
 * @param string $url The URL or path to the file.
 * @return string The MIME type of the file.
 */
function getMimeType($url) {
	static $mime = null;

	if (is_null($mime)) {
		// Default values, overriden by /etc/mime.types when present
		$mime = array(
			'jpg' => 'image/jpeg',
			'gif' => 'image/gif',
			'png' => 'image/png',
			'image' => 'image/*'
		);
		// '@' is used to mute open_basedir warning, see issue #818
		if (@is_readable('/etc/mime.types')) {
			$file = fopen('/etc/mime.types', 'r');
			while(($line = fgets($file)) !== false) {
				$line = trim(preg_replace('/#.*/', '', $line));
				if(!$line)
					continue;
				$parts = preg_split('/\s+/', $line);
				if(count($parts) == 1)
					continue;
				$type = array_shift($parts);
				foreach($parts as $part)
					$mime[$part] = $type;
			}
			fclose($file);
		}
	}

	if (strpos($url, '?') !== false) {
		$url_temp = substr($url, 0, strpos($url, '?'));
		if (strpos($url, '#') !== false) {
			$anchor = substr($url, strpos($url, '#'));
			$url_temp .= $anchor;
		}
		$url = $url_temp;
	}

	$ext = strtolower(pathinfo($url, PATHINFO_EXTENSION));
	if (!empty($mime[$ext])) {
		return $mime[$ext];
	}

	return 'application/octet-stream';
}