Passed
Push — master ( 4765f5...78c7e6 )
by Roeland
11:41 queued 14s
created

RefreshWebcalJob::fixSubscriptionRowTyping()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 12
rs 9.9666
c 0
b 0
f 0
cc 3
nc 3
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright 2018 Georg Ehrke <[email protected]>
7
 *
8
 * @author Georg Ehrke <[email protected]>
9
 * @author Roeland Jago Douma <[email protected]>
10
 *
11
 * @license GNU AGPL version 3 or any later version
12
 *
13
 * This program is free software: you can redistribute it and/or modify
14
 * it under the terms of the GNU Affero General Public License as
15
 * published by the Free Software Foundation, either version 3 of the
16
 * License, or (at your option) any later version.
17
 *
18
 * This program is distributed in the hope that it will be useful,
19
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21
 * GNU Affero General Public License for more details.
22
 *
23
 * You should have received a copy of the GNU Affero General Public License
24
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
25
 *
26
 */
27
28
namespace OCA\DAV\BackgroundJob;
29
30
use GuzzleHttp\HandlerStack;
31
use GuzzleHttp\Middleware;
32
use OC\BackgroundJob\Job;
33
use OCA\DAV\CalDAV\CalDavBackend;
34
use OCP\AppFramework\Utility\ITimeFactory;
35
use OCP\Http\Client\IClientService;
36
use OCP\IConfig;
37
use OCP\ILogger;
38
use Psr\Http\Message\RequestInterface;
39
use Psr\Http\Message\ResponseInterface;
40
use Sabre\DAV\Exception\BadRequest;
41
use Sabre\DAV\PropPatch;
42
use Sabre\DAV\Xml\Property\Href;
43
use Sabre\VObject\Component;
44
use Sabre\VObject\DateTimeParser;
45
use Sabre\VObject\InvalidDataException;
46
use Sabre\VObject\ParseException;
47
use Sabre\VObject\Reader;
48
use Sabre\VObject\Splitter\ICalendar;
49
50
class RefreshWebcalJob extends Job {
51
52
	/** @var CalDavBackend */
53
	private $calDavBackend;
54
55
	/** @var IClientService */
56
	private $clientService;
57
58
	/** @var IConfig */
59
	private $config;
60
61
	/** @var ILogger */
62
	private $logger;
63
64
	/** @var ITimeFactory */
65
	private $timeFactory;
66
67
	/** @var array */
68
	private $subscription;
69
70
	private const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate';
71
	private const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms';
72
	private const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments';
73
	private const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos';
74
75
	/**
76
	 * RefreshWebcalJob constructor.
77
	 *
78
	 * @param CalDavBackend $calDavBackend
79
	 * @param IClientService $clientService
80
	 * @param IConfig $config
81
	 * @param ILogger $logger
82
	 * @param ITimeFactory $timeFactory
83
	 */
84
	public function __construct(CalDavBackend $calDavBackend, IClientService $clientService, IConfig $config, ILogger $logger, ITimeFactory $timeFactory) {
85
		$this->calDavBackend = $calDavBackend;
86
		$this->clientService = $clientService;
87
		$this->config = $config;
88
		$this->logger = $logger;
89
		$this->timeFactory = $timeFactory;
90
	}
91
92
	/**
93
	 * this function is called at most every hour
94
	 *
95
	 * @inheritdoc
96
	 */
97
	public function execute($jobList, ILogger $logger = null) {
98
		$subscription = $this->getSubscription($this->argument['principaluri'], $this->argument['uri']);
99
		if (!$subscription) {
100
			return;
101
		}
102
103
		$this->fixSubscriptionRowTyping($subscription);
104
105
		// if no refresh rate was configured, just refresh once a week
106
		$subscriptionId = $subscription['id'];
107
		$refreshrate = $subscription[self::REFRESH_RATE] ?? 'P1W';
108
109
		try {
110
			/** @var \DateInterval $dateInterval */
111
			$dateInterval = DateTimeParser::parseDuration($refreshrate);
112
		} catch(InvalidDataException $ex) {
113
			$this->logger->logException($ex);
114
			$this->logger->warning("Subscription $subscriptionId could not be refreshed, refreshrate in database is invalid");
115
			return;
116
		}
117
118
		$interval = $this->getIntervalFromDateInterval($dateInterval);
119
		if (($this->timeFactory->getTime() - $this->lastRun) <= $interval) {
120
			return;
121
		}
122
123
		parent::execute($jobList, $logger);
124
	}
125
126
	/**
127
	 * @param array $argument
128
	 */
129
	protected function run($argument) {
130
		$subscription = $this->getSubscription($argument['principaluri'], $argument['uri']);
131
		$mutations = [];
132
		if (!$subscription) {
133
			return;
134
		}
135
136
		$webcalData = $this->queryWebcalFeed($subscription, $mutations);
137
		if (!$webcalData) {
138
			return;
139
		}
140
141
		$stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
142
		$stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
143
		$stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
144
145
		try {
146
			$splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING);
0 ignored issues
show
Bug introduced by
$webcalData of type string is incompatible with the type resource expected by parameter $input of Sabre\VObject\Splitter\ICalendar::__construct(). ( Ignorable by Annotation )

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

146
			$splitter = new ICalendar(/** @scrutinizer ignore-type */ $webcalData, Reader::OPTION_FORGIVING);
Loading history...
147
148
			// we wait with deleting all outdated events till we parsed the new ones
149
			// in case the new calendar is broken and `new ICalendar` throws a ParseException
150
			// the user will still see the old data
151
			$this->calDavBackend->purgeAllCachedEventsForSubscription($subscription['id']);
152
153
			while ($vObject = $splitter->getNext()) {
154
				/** @var Component $vObject */
155
				$uid = null;
156
				$compName = null;
157
158
				foreach ($vObject->getComponents() as $component) {
159
					if ($component->name === 'VTIMEZONE') {
160
						continue;
161
					}
162
163
					$uid = $component->{'UID'}->getValue();
164
					$compName = $component->name;
165
166
					if ($stripAlarms) {
167
						unset($component->{'VALARM'});
168
					}
169
					if ($stripAttachments) {
170
						unset($component->{'ATTACH'});
171
					}
172
				}
173
174
				if ($stripTodos && $compName === 'VTODO') {
175
					continue;
176
				}
177
178
				$uri = $uid . '.ics';
179
				$calendarData = $vObject->serialize();
180
				try {
181
					$this->calDavBackend->createCalendarObject($subscription['id'], $uri, $calendarData, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
182
				} catch(BadRequest $ex) {
183
					$this->logger->logException($ex);
184
				}
185
			}
186
187
			$newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData);
188
			if ($newRefreshRate) {
189
				$mutations[self::REFRESH_RATE] = $newRefreshRate;
190
			}
191
192
			$this->updateSubscription($subscription, $mutations);
193
		} catch(ParseException $ex) {
194
			$subscriptionId = $subscription['id'];
195
196
			$this->logger->logException($ex);
197
			$this->logger->warning("Subscription $subscriptionId could not be refreshed due to a parsing error");
198
		}
199
	}
200
201
	/**
202
	 * gets webcal feed from remote server
203
	 *
204
	 * @param array $subscription
205
	 * @param array &$mutations
206
	 * @return null|string
207
	 */
208
	private function queryWebcalFeed(array $subscription, array &$mutations) {
209
		$client = $this->clientService->newClient();
210
211
		$didBreak301Chain = false;
212
		$latestLocation = null;
213
214
		$handlerStack = HandlerStack::create();
215
		$handlerStack->push(Middleware::mapRequest(function (RequestInterface $request) {
216
			return $request
217
				->withHeader('Accept', 'text/calendar, application/calendar+json, application/calendar+xml')
218
				->withHeader('User-Agent', 'Nextcloud Webcal Crawler');
219
		}));
220
		$handlerStack->push(Middleware::mapResponse(function(ResponseInterface $response) use (&$didBreak301Chain, &$latestLocation) {
221
			if (!$didBreak301Chain) {
222
				if ($response->getStatusCode() !== 301) {
223
					$didBreak301Chain = true;
224
				} else {
225
					$latestLocation = $response->getHeader('Location');
226
				}
227
			}
228
			return $response;
229
		}));
230
231
		$allowLocalAccess = $this->config->getAppValue('dav', 'webcalAllowLocalAccess', 'no');
232
		$subscriptionId = $subscription['id'];
233
		$url = $this->cleanURL($subscription['source']);
234
		if ($url === null) {
235
			return null;
236
		}
237
238
		if ($allowLocalAccess !== 'yes') {
239
			$host = strtolower(parse_url($url, PHP_URL_HOST));
240
			// remove brackets from IPv6 addresses
241
			if (strpos($host, '[') === 0 && substr($host, -1) === ']') {
242
				$host = substr($host, 1, -1);
243
			}
244
245
			// Disallow localhost and local network
246
			if ($host === 'localhost' || substr($host, -6) === '.local' || substr($host, -10) === '.localhost') {
247
				$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
248
				return null;
249
			}
250
251
			// Disallow hostname only
252
			if (substr_count($host, '.') === 0) {
253
				$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
254
				return null;
255
			}
256
257
			if ((bool)filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
258
				$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
259
				return null;
260
			}
261
262
			// Also check for IPv6 IPv4 nesting, because that's not covered by filter_var
263
			if ((bool)filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && substr_count($host, '.') > 0) {
264
				$delimiter = strrpos($host, ':'); // Get last colon
265
				$ipv4Address = substr($host, $delimiter + 1);
266
267
				if (!filter_var($ipv4Address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
268
					$this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules");
269
					return null;
270
				}
271
			}
272
		}
273
274
		try {
275
			$params = [
276
				'allow_redirects' => [
277
					'redirects' => 10
278
				],
279
				'handler' => $handlerStack,
280
			];
281
282
			$user = parse_url($subscription['source'], PHP_URL_USER);
283
			$pass = parse_url($subscription['source'], PHP_URL_PASS);
284
			if ($user !== null && $pass !== null) {
285
				$params['auth'] = [$user, $pass];
286
			}
287
288
			$response = $client->get($url, $params);
289
			$body = $response->getBody();
290
291
			if ($latestLocation) {
292
				$mutations['{http://calendarserver.org/ns/}source'] = new Href($latestLocation);
293
			}
294
295
			$contentType = $response->getHeader('Content-Type');
296
			$contentType = explode(';', $contentType, 2)[0];
297
			switch($contentType) {
298
				case 'application/calendar+json':
299
					try {
300
						$jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING);
301
					} catch(\Exception $ex) {
302
						// In case of a parsing error return null
303
						$this->logger->debug("Subscription $subscriptionId could not be parsed");
304
						return null;
305
					}
306
					return $jCalendar->serialize();
307
308
				case 'application/calendar+xml':
309
					try {
310
						$xCalendar = Reader::readXML($body);
311
					} catch(\Exception $ex) {
312
						// In case of a parsing error return null
313
						$this->logger->debug("Subscription $subscriptionId could not be parsed");
314
						return null;
315
					}
316
					return $xCalendar->serialize();
317
318
				case 'text/calendar':
319
				default:
320
					try {
321
						$vCalendar = Reader::read($body);
322
					} catch(\Exception $ex) {
323
						// In case of a parsing error return null
324
						$this->logger->debug("Subscription $subscriptionId could not be parsed");
325
						return null;
326
					}
327
					return $vCalendar->serialize();
328
			}
329
		} catch(\Exception $ex) {
330
			$this->logger->logException($ex);
331
			$this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error");
332
333
			return null;
334
		}
335
	}
336
337
	/**
338
	 * loads subscription from backend
339
	 *
340
	 * @param string $principalUri
341
	 * @param string $uri
342
	 * @return array|null
343
	 */
344
	private function getSubscription(string $principalUri, string $uri) {
345
		$subscriptions = array_values(array_filter(
346
			$this->calDavBackend->getSubscriptionsForUser($principalUri),
347
			function($sub) use ($uri) {
348
				return $sub['uri'] === $uri;
349
			}
350
		));
351
352
		if (\count($subscriptions) === 0) {
353
			return null;
354
		}
355
356
		$this->subscription = $subscriptions[0];
357
		return $this->subscription;
358
	}
359
360
	/**
361
	 * get total number of seconds from DateInterval object
362
	 *
363
	 * @param \DateInterval $interval
364
	 * @return int
365
	 */
366
	private function getIntervalFromDateInterval(\DateInterval $interval):int {
367
		return $interval->s
368
			+ ($interval->i * 60)
369
			+ ($interval->h * 60 * 60)
370
			+ ($interval->d * 60 * 60 * 24)
371
			+ ($interval->m * 60 * 60 * 24 * 30)
372
			+ ($interval->y * 60 * 60 * 24 * 365);
373
	}
374
375
	/**
376
	 * check if:
377
	 *  - current subscription stores a refreshrate
378
	 *  - the webcal feed suggests a refreshrate
379
	 *  - return suggested refreshrate if user didn't set a custom one
380
	 *
381
	 * @param array $subscription
382
	 * @param string $webcalData
383
	 * @return string|null
384
	 */
385
	private function checkWebcalDataForRefreshRate($subscription, $webcalData) {
386
		// if there is no refreshrate stored in the database, check the webcal feed
387
		// whether it suggests any refresh rate and store that in the database
388
		if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) {
389
			return null;
390
		}
391
392
		/** @var Component\VCalendar $vCalendar */
393
		$vCalendar = Reader::read($webcalData);
394
395
		$newRefreshRate = null;
396
		if (isset($vCalendar->{'X-PUBLISHED-TTL'})) {
397
			$newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue();
398
		}
399
		if (isset($vCalendar->{'REFRESH-INTERVAL'})) {
400
			$newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue();
401
		}
402
403
		if (!$newRefreshRate) {
404
			return null;
405
		}
406
407
		// check if new refresh rate is even valid
408
		try {
409
			DateTimeParser::parseDuration($newRefreshRate);
410
		} catch(InvalidDataException $ex) {
411
			return null;
412
		}
413
414
		return $newRefreshRate;
415
	}
416
417
	/**
418
	 * update subscription stored in database
419
	 * used to set:
420
	 *  - refreshrate
421
	 *  - source
422
	 *
423
	 * @param array $subscription
424
	 * @param array $mutations
425
	 */
426
	private function updateSubscription(array $subscription, array $mutations) {
427
		if (empty($mutations)) {
428
			return;
429
		}
430
431
		$propPatch = new PropPatch($mutations);
432
		$this->calDavBackend->updateSubscription($subscription['id'], $propPatch);
433
		$propPatch->commit();
434
	}
435
436
	/**
437
	 * This method will strip authentication information and replace the
438
	 * 'webcal' or 'webcals' protocol scheme
439
	 *
440
	 * @param string $url
441
	 * @return string|null
442
	 */
443
	private function cleanURL(string $url) {
444
		$parsed = parse_url($url);
445
		if ($parsed === false) {
446
			return null;
447
		}
448
449
		if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') {
450
			$scheme = 'http';
451
		} else {
452
			$scheme = 'https';
453
		}
454
455
		$host = $parsed['host'] ?? '';
456
		$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
457
		$path = $parsed['path'] ?? '';
458
		$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
459
		$fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
460
461
		$cleanURL = "$scheme://$host$port$path$query$fragment";
462
		// parse_url is giving some weird results if no url and no :// is given,
463
		// so let's test the url again
464
		$parsedClean = parse_url($cleanURL);
465
		if ($parsedClean === false || !isset($parsedClean['host'])) {
466
			return null;
467
		}
468
469
		return $cleanURL;
470
	}
471
472
	/**
473
	 * Fixes types of rows
474
	 *
475
	 * @param array $row
476
	 */
477
	private function fixSubscriptionRowTyping(array &$row):void {
478
		$forceInt = [
479
			'id',
480
			'lastmodified',
481
			self::STRIP_ALARMS,
482
			self::STRIP_ATTACHMENTS,
483
			self::STRIP_TODOS,
484
		];
485
486
		foreach($forceInt as $column) {
487
			if (isset($row[$column])) {
488
				$row[$column] = (int) $row[$column];
489
			}
490
		}
491
	}
492
}
493