Passed
Push — master ( 95ad9a...5c0637 )
by Joas
12:15 queued 11s
created

RefreshWebcalService   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 346
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 166
dl 0
loc 346
rs 8.4
c 0
b 0
f 0
wmc 50

8 Methods

Rating   Name   Duplication   Size   Complexity  
C queryWebcalFeed() 0 102 15
A __construct() 0 5 1
B cleanURL() 0 27 9
B checkWebcalDataForRefreshRate() 0 30 7
A getRandomCalendarObjectUri() 0 2 1
A getSubscription() 0 13 2
C refreshSubscription() 0 67 13
A updateSubscription() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like RefreshWebcalService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RefreshWebcalService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright Copyright (c) 2020, Thomas Citharel <[email protected]>
7
 *
8
 * @author Georg Ehrke <[email protected]>
9
 * @author Thomas Citharel <[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\CalDAV\WebcalCaching;
29
30
use Exception;
31
use GuzzleHttp\HandlerStack;
32
use GuzzleHttp\Middleware;
33
use OCA\DAV\CalDAV\CalDavBackend;
34
use OCP\Http\Client\IClientService;
35
use OCP\Http\Client\LocalServerException;
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
use Sabre\VObject\UUIDUtil;
50
use function count;
51
52
class RefreshWebcalService {
53
54
	/** @var CalDavBackend */
55
	private $calDavBackend;
56
57
	/** @var IClientService */
58
	private $clientService;
59
60
	/** @var IConfig */
61
	private $config;
62
63
	/** @var ILogger */
64
	private $logger;
65
66
	public const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate';
67
	public const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms';
68
	public const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments';
69
	public const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos';
70
71
	/**
72
	 * RefreshWebcalJob constructor.
73
	 *
74
	 * @param CalDavBackend $calDavBackend
75
	 * @param IClientService $clientService
76
	 * @param IConfig $config
77
	 * @param ILogger $logger
78
	 */
79
	public function __construct(CalDavBackend $calDavBackend, IClientService $clientService, IConfig $config, ILogger $logger) {
80
		$this->calDavBackend = $calDavBackend;
81
		$this->clientService = $clientService;
82
		$this->config = $config;
83
		$this->logger = $logger;
84
	}
85
86
	/**
87
	 * @param string $principalUri
88
	 * @param string $uri
89
	 */
90
	public function refreshSubscription(string $principalUri, string $uri) {
91
		$subscription = $this->getSubscription($principalUri, $uri);
92
		$mutations = [];
93
		if (!$subscription) {
94
			return;
95
		}
96
97
		$webcalData = $this->queryWebcalFeed($subscription, $mutations);
98
		if (!$webcalData) {
99
			return;
100
		}
101
102
		$stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
103
		$stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
104
		$stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
105
106
		try {
107
			$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

107
			$splitter = new ICalendar(/** @scrutinizer ignore-type */ $webcalData, Reader::OPTION_FORGIVING);
Loading history...
108
109
			// we wait with deleting all outdated events till we parsed the new ones
110
			// in case the new calendar is broken and `new ICalendar` throws a ParseException
111
			// the user will still see the old data
112
			$this->calDavBackend->purgeAllCachedEventsForSubscription($subscription['id']);
113
114
			while ($vObject = $splitter->getNext()) {
115
				/** @var Component $vObject */
116
				$compName = null;
117
118
				foreach ($vObject->getComponents() as $component) {
119
					if ($component->name === 'VTIMEZONE') {
120
						continue;
121
					}
122
123
					$compName = $component->name;
124
125
					if ($stripAlarms) {
126
						unset($component->{'VALARM'});
127
					}
128
					if ($stripAttachments) {
129
						unset($component->{'ATTACH'});
130
					}
131
				}
132
133
				if ($stripTodos && $compName === 'VTODO') {
134
					continue;
135
				}
136
137
				$uri = $this->getRandomCalendarObjectUri();
138
				$calendarData = $vObject->serialize();
139
				try {
140
					$this->calDavBackend->createCalendarObject($subscription['id'], $uri, $calendarData, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
141
				} catch (BadRequest $ex) {
142
					$this->logger->logException($ex);
143
				}
144
			}
145
146
			$newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData);
147
			if ($newRefreshRate) {
148
				$mutations[self::REFRESH_RATE] = $newRefreshRate;
149
			}
150
151
			$this->updateSubscription($subscription, $mutations);
152
		} catch (ParseException $ex) {
153
			$subscriptionId = $subscription['id'];
154
155
			$this->logger->logException($ex);
156
			$this->logger->warning("Subscription $subscriptionId could not be refreshed due to a parsing error");
157
		}
158
	}
159
160
	/**
161
	 * loads subscription from backend
162
	 *
163
	 * @param string $principalUri
164
	 * @param string $uri
165
	 * @return array|null
166
	 */
167
	public function getSubscription(string $principalUri, string $uri) {
168
		$subscriptions = array_values(array_filter(
169
			$this->calDavBackend->getSubscriptionsForUser($principalUri),
170
			function ($sub) use ($uri) {
171
				return $sub['uri'] === $uri;
172
			}
173
		));
174
175
		if (count($subscriptions) === 0) {
176
			return null;
177
		}
178
179
		return $subscriptions[0];
180
	}
181
182
	/**
183
	 * gets webcal feed from remote server
184
	 *
185
	 * @param array $subscription
186
	 * @param array &$mutations
187
	 * @return null|string
188
	 */
189
	private function queryWebcalFeed(array $subscription, array &$mutations) {
190
		$client = $this->clientService->newClient();
191
192
		$didBreak301Chain = false;
193
		$latestLocation = null;
194
195
		$handlerStack = HandlerStack::create();
196
		$handlerStack->push(Middleware::mapRequest(function (RequestInterface $request) {
197
			return $request
198
				->withHeader('Accept', 'text/calendar, application/calendar+json, application/calendar+xml')
199
				->withHeader('User-Agent', 'Nextcloud Webcal Crawler');
200
		}));
201
		$handlerStack->push(Middleware::mapResponse(function (ResponseInterface $response) use (&$didBreak301Chain, &$latestLocation) {
202
			if (!$didBreak301Chain) {
203
				if ($response->getStatusCode() !== 301) {
204
					$didBreak301Chain = true;
205
				} else {
206
					$latestLocation = $response->getHeader('Location');
207
				}
208
			}
209
			return $response;
210
		}));
211
212
		$allowLocalAccess = $this->config->getAppValue('dav', 'webcalAllowLocalAccess', 'no');
213
		$subscriptionId = $subscription['id'];
214
		$url = $this->cleanURL($subscription['source']);
215
		if ($url === null) {
216
			return null;
217
		}
218
219
		try {
220
			$params = [
221
				'allow_redirects' => [
222
					'redirects' => 10
223
				],
224
				'handler' => $handlerStack,
225
				'nextcloud' => [
226
					'allow_local_address' => $allowLocalAccess === 'yes',
227
				]
228
			];
229
230
			$user = parse_url($subscription['source'], PHP_URL_USER);
231
			$pass = parse_url($subscription['source'], PHP_URL_PASS);
232
			if ($user !== null && $pass !== null) {
233
				$params['auth'] = [$user, $pass];
234
			}
235
236
			$response = $client->get($url, $params);
237
			$body = $response->getBody();
238
239
			if ($latestLocation) {
240
				$mutations['{http://calendarserver.org/ns/}source'] = new Href($latestLocation);
241
			}
242
243
			$contentType = $response->getHeader('Content-Type');
244
			$contentType = explode(';', $contentType, 2)[0];
245
			switch ($contentType) {
246
				case 'application/calendar+json':
247
					try {
248
						$jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING);
249
					} catch (Exception $ex) {
250
						// In case of a parsing error return null
251
						$this->logger->debug("Subscription $subscriptionId could not be parsed");
252
						return null;
253
					}
254
					return $jCalendar->serialize();
255
256
				case 'application/calendar+xml':
257
					try {
258
						$xCalendar = Reader::readXML($body);
259
					} catch (Exception $ex) {
260
						// In case of a parsing error return null
261
						$this->logger->debug("Subscription $subscriptionId could not be parsed");
262
						return null;
263
					}
264
					return $xCalendar->serialize();
265
266
				case 'text/calendar':
267
				default:
268
					try {
269
						$vCalendar = Reader::read($body);
270
					} catch (Exception $ex) {
271
						// In case of a parsing error return null
272
						$this->logger->debug("Subscription $subscriptionId could not be parsed");
273
						return null;
274
					}
275
					return $vCalendar->serialize();
276
			}
277
		} catch (LocalServerException $ex) {
278
			$this->logger->logException($ex, [
279
				'message' => "Subscription $subscriptionId was not refreshed because it violates local access rules",
280
				'level' => ILogger::WARN,
281
			]);
282
283
			return null;
284
		} catch (Exception $ex) {
285
			$this->logger->logException($ex, [
286
				'message' => "Subscription $subscriptionId could not be refreshed due to a network error",
287
				'level' => ILogger::WARN,
288
			]);
289
290
			return null;
291
		}
292
	}
293
294
	/**
295
	 * check if:
296
	 *  - current subscription stores a refreshrate
297
	 *  - the webcal feed suggests a refreshrate
298
	 *  - return suggested refreshrate if user didn't set a custom one
299
	 *
300
	 * @param array $subscription
301
	 * @param string $webcalData
302
	 * @return string|null
303
	 */
304
	private function checkWebcalDataForRefreshRate($subscription, $webcalData) {
305
		// if there is no refreshrate stored in the database, check the webcal feed
306
		// whether it suggests any refresh rate and store that in the database
307
		if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) {
308
			return null;
309
		}
310
311
		/** @var Component\VCalendar $vCalendar */
312
		$vCalendar = Reader::read($webcalData);
313
314
		$newRefreshRate = null;
315
		if (isset($vCalendar->{'X-PUBLISHED-TTL'})) {
316
			$newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue();
317
		}
318
		if (isset($vCalendar->{'REFRESH-INTERVAL'})) {
319
			$newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue();
320
		}
321
322
		if (!$newRefreshRate) {
323
			return null;
324
		}
325
326
		// check if new refresh rate is even valid
327
		try {
328
			DateTimeParser::parseDuration($newRefreshRate);
329
		} catch (InvalidDataException $ex) {
330
			return null;
331
		}
332
333
		return $newRefreshRate;
334
	}
335
336
	/**
337
	 * update subscription stored in database
338
	 * used to set:
339
	 *  - refreshrate
340
	 *  - source
341
	 *
342
	 * @param array $subscription
343
	 * @param array $mutations
344
	 */
345
	private function updateSubscription(array $subscription, array $mutations) {
346
		if (empty($mutations)) {
347
			return;
348
		}
349
350
		$propPatch = new PropPatch($mutations);
351
		$this->calDavBackend->updateSubscription($subscription['id'], $propPatch);
352
		$propPatch->commit();
353
	}
354
355
	/**
356
	 * This method will strip authentication information and replace the
357
	 * 'webcal' or 'webcals' protocol scheme
358
	 *
359
	 * @param string $url
360
	 * @return string|null
361
	 */
362
	private function cleanURL(string $url) {
363
		$parsed = parse_url($url);
364
		if ($parsed === false) {
365
			return null;
366
		}
367
368
		if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') {
369
			$scheme = 'http';
370
		} else {
371
			$scheme = 'https';
372
		}
373
374
		$host = $parsed['host'] ?? '';
375
		$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
376
		$path = $parsed['path'] ?? '';
377
		$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
378
		$fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
379
380
		$cleanURL = "$scheme://$host$port$path$query$fragment";
381
		// parse_url is giving some weird results if no url and no :// is given,
382
		// so let's test the url again
383
		$parsedClean = parse_url($cleanURL);
384
		if ($parsedClean === false || !isset($parsedClean['host'])) {
385
			return null;
386
		}
387
388
		return $cleanURL;
389
	}
390
391
	/**
392
	 * Returns a random uri for a calendar-object
393
	 *
394
	 * @return string
395
	 */
396
	public function getRandomCalendarObjectUri():string {
397
		return UUIDUtil::getUUID() . '.ics';
398
	}
399
}
400