Completed
Push — develop ( f2bd12...f57c6e )
by Seth
06:57
created

import.php ➔ getCanvasContext()   C

Complexity

Conditions 7
Paths 6

Size

Total Lines 37
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 7
eloc 21
c 4
b 0
f 0
nc 6
nop 1
dl 0
loc 37
rs 6.7272
1
<?php
0 ignored issues
show
Coding Style Compatibility introduced by
For compatibility and reusability of your code, PSR1 recommends that a file should introduce either new symbols (like classes, functions, etc.) or have side-effects (like outputting something, or including other files), but not both at the same time. The first symbol is defined on line 9 and the first side effect is on line 5.

The PSR-1: Basic Coding Standard recommends that a file should either introduce new symbols, that is classes, functions, constants or similar, or have side effects. Side effects are anything that executes logic, like for example printing output, changing ini settings or writing to a file.

The idea behind this recommendation is that merely auto-loading a class should not change the state of an application. It also promotes a cleaner style of programming and makes your code less prone to errors, because the logic is not spread out all over the place.

To learn more about the PSR-1, please see the PHP-FIG site on the PSR-1.

Loading history...
2
3
if (php_sapi_name() == 'cli') {
4
	// TODO there is a more streamlined way of doing this that escapes me just this second
5
	$_REQUEST['cal'] = $argv[1];
6
	$_REQUEST['canvas_url'] = $argv[2];
7
	$_REQUEST['schedule'] = $argv[3];
8
9
	define ('IGNORE_LTI', true);
10
}
11
12
require_once 'common.inc.php';
13
14
use smtech\CanvasPest\CanvasPest;
15
use Battis\BootstrapSmarty\NotificationMessage;
16
17
/**
18
 * Check to see if a URL exists
19
 **/
20
function urlExists($url) {
21
	$handle = fopen($url, 'r');
22
	return $handle !== false;
23
}
24
25
/**
26
 * compute the calendar context for the canvas object based on its URL
27
 **/
28
function getCanvasContext($canvasUrl) {
0 ignored issues
show
Unused Code introduced by
The parameter $canvasUrl is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Coding Style introduced by
getCanvasContext uses the super-global variable $_REQUEST which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
29
	global $metadata;
0 ignored issues
show
Compatibility Best Practice introduced by
Use of global functionality is not recommended; it makes your code harder to test, and less reusable.

Instead of relying on global state, we recommend one of these alternatives:

1. Pass all data via parameters

function myFunction($a, $b) {
    // Do something
}

2. Create a class that maintains your state

class MyClass {
    private $a;
    private $b;

    public function __construct($a, $b) {
        $this->a = $a;
        $this->b = $b;
    }

    public function myFunction() {
        // Do something
    }
}
Loading history...
30
31
	// TODO: accept calendar2?contexts links too (they would be an intuitively obvious link to use, after all)
32
	// FIXME: users aren't working
33
	// TODO: it would probably be better to look up users by email address than URL
34
	/* get the context (user, course or group) for the canvas URL */
35
	$canvasContext = array();
36
	if (preg_match('%(https?://)?(' . parse_url($metadata['CANVAS_INSTANCE_URL'], PHP_URL_HOST) . '/((about/(\d+))|(courses/(\d+)(/groups/(\d+))?)|(accounts/\d+/groups/(\d+))))%', $_REQUEST['canvas_url'], $matches)) {
37
		$canvasContext['canonical_url'] = "https://{$matches[2]}"; // https://stmarksschool.instructure.com/courses/953
38
39
		// course or account groups
40
		if (isset($matches[9]) || isset($matches[11])) {
41
			$canvasContext['context'] = 'group'; // used to for context_code in events
42
			$canvasContext['id'] = ($matches[9] > $matches[11] ? $matches[9] : $matches[11]);
43
			$canvasContext['verification_url'] = "groups/{$canvasContext['id']}"; // used once to look up the object to be sure it really exists
44
45
		// courses
46
		} elseif (isset($matches[7])) {
47
			$canvasContext['context'] = 'course';
48
			$canvasContext['id'] = $matches[7];
49
			$canvasContext['verification_url'] = "courses/{$canvasContext['id']}";
50
51
		// users
52
		} elseif (isset($matches[5])) {
53
			$canvasContext['context'] = 'user';
54
			$canvasContext['id'] = $matches[5];
55
			$canvasContext['verification_url'] = "users/{$canvasContext['id']}/profile";
56
57
		// we're somewhere where we don't know where we are
58
		} else {
59
			return false;
60
		}
61
		return $canvasContext;
62
	}
63
	return false;
64
}
65
66
/**
67
 * Filter and clean event data before posting to Canvas
68
 *
69
 * This must happen AFTER the event hash has been calculated!
70
 **/
71
function filterEvent($event, $calendarCache) {
72
73
 	return (
74
	 	// include this event if filtering is off...
75
 		$calendarCache['enable_regexp_filter'] == false ||
76
 		(
77
			(
78
				( // if filtering is on, and there's an include pattern test that pattern...
79
					!empty($calendarCache['include_regexp']) &&
80
					preg_match("%{$calendarCache['include_regexp']}%", $event->getProperty('SUMMARY'))
81
				)
82
			) &&
83
			!( // if there is an exclude pattern, make sure that this event is NOT excluded
84
				!empty($calendarCache['exclude_regexp']) &&
85
				preg_match("%{$calendarCache['exclude_regexp']}%", $event->getProperty('SUMMARY'))
86
			)
87
		)
88
	);
89
}
90
91
// TODO: it would be nice to be able to cleanly remove a synched calendar
92
// TODO: it would be nice to be able unschedule a scheduled sync without removing the calendar
93
// TODO: how about something to extirpate non-synced data (could be done right now by brute force -- once overwrite is implemented -- by marking all of the cached events as invalid and then importing the calendar and overwriting, but that's a little icky)
94
// TODO: right now, if a user changes a synced event in Canvas, it will never get "corrected" back to the ICS feed... we could cache the Canvas events as well as the ICS feed and do a periodic (much less frequent, given the speed of looking everything up in the API) check and re-sync modified events too
95
96
/* do we have the vital information (an ICS feed and a URL to a canvas
97
   object)? */
98
if (isset($_REQUEST['cal']) && isset($_REQUEST['canvas_url'])) {
99
100
	if ($canvasContext = getCanvasContext($_REQUEST['canvas_url'])) {
101
		/* check ICS feed to be sure it exists */
102
		if(urlExists($_REQUEST['cal'])) {
103
			/* look up the canvas object -- mostly to make sure that it exists! */
104
			if ($canvasObject = $api->get($canvasContext['verification_url'])) {
105
106
				/* calculate the unique pairing ID of this ICS feed and canvas object */
107
				$pairingHash = getPairingHash($_REQUEST['cal'], $canvasContext['canonical_url']);
108
				$log = Log::singleton('file', __DIR__ . "/logs/$pairingHash.log");
109
				postMessage('Sync started', getSyncTimestamp(), NotificationMessage::INFO);
110
111
				/* tell users that it's started and to cool their jets */
112
				if (php_sapi_name() != 'cli') {
113
					$smarty->assign('content', '
114
						<h3>Calendar Import Started</h3>
115
						<p>The calendar import that you requested has begun. You may leave this page at anytime. You can see the progress of the import by visiting <a target="_blank" href="https://' . parse_url($metadata['CANVAS_INSTANCE_URL'], PHP_URL_HOST) . "/calendar?include_contexts={$canvasContext['context']}_{$canvasObject['id']}\">this calendar</a> in Canvas.</p>"
116
					);
117
					$smarty->display('page.tpl');
118
				}
119
120
				/* parse the ICS feed */
121
				$ics = new vcalendar(
122
					array(
123
						'unique_id' => $metadata['APP_ID'],
124
						'url' => $_REQUEST['cal']
125
					)
126
				);
127
				$ics->parse();
128
129
				/* log this pairing in the database cache, if it doesn't already exist */
130
				$calendarCacheResponse = $sql->query("
131
					SELECT *
132
						FROM `calendars`
133
						WHERE
134
							`id` = '$pairingHash'
135
				");
136
				$calendarCache = $calendarCacheResponse->fetch_assoc();
137
138
				/* if the calendar is already cached, just update the sync timestamp */
139
				if ($calendarCache) {
140
					$sql->query("
141
						UPDATE `calendars`
142
							SET
143
								`synced` = '" . getSyncTimestamp() . "'
144
							WHERE
145
								`id` = '$pairingHash'
146
					");
147
				} else {
148
					$sql->query("
149
						INSERT INTO `calendars`
150
							(
151
								`id`,
152
								`name`,
153
								`ics_url`,
154
								`canvas_url`,
155
								`synced`,
156
								`enable_regexp_filter`,
157
								`include_regexp`,
158
								`exclude_regexp`
159
							)
160
							VALUES (
161
								'$pairingHash',
162
								'" . $sql->real_escape_string($ics->getProperty('X-WR-CALNAME')) . "',
163
								'{$_REQUEST['cal']}',
164
								'{$canvasContext['canonical_url']}',
165
								'" . getSyncTimestamp() . "',
166
								'" . ($_REQUEST['enable_regexp_filter'] == VALUE_ENABLE_REGEXP_FILTER) . "',
167
								" . ($_REQUEST['enable_regexp_filter'] == VALUE_ENABLE_REGEXP_FILTER ? "'" . $sql->real_escape_string($_REQUEST['include_regexp']) . "'" : 'NULL') . ",
168
								" . ($_REQUEST['enable_regexp_filter'] == VALUE_ENABLE_REGEXP_FILTER ? "'" . $sql->real_escape_string($_REQUEST['exclude_regexp']) . "'" : 'NULL') . "
169
							)
170
					");
171
				}
172
173
				/* refresh calendar information from cache database */
174
				$calendarCacheResponse = $sql->query("
175
					SELECT *
176
						FROM `calendars`
177
						WHERE
178
							`id` = '$pairingHash'
179
				");
180
				$calendarCache = $calendarCacheResponse->fetch_assoc();
181
182
				/* walk through $master_array and update the Canvas calendar to match the
183
				   ICS feed, caching changes in the database */
184
				// TODO: would it be worth the performance improvement to just process things from today's date forward? (i.e. ignore old items, even if they've changed...)
185
				// TODO:0 the best window for syncing would be the term of the course in question, right? issue:12
186
				// TODO:0 Arbitrarily selecting events in for a year on either side of today's date, probably a better system? issue:12
187
				foreach ($ics->selectComponents(
0 ignored issues
show
Bug introduced by
The expression $ics->selectComponents(d...nt', false, true, true) of type false|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
188
					date('Y')-1, // startYear
189
					date('m'), // startMonth
190
					date('d'), // startDay
191
					date('Y')+1, // endYEar
192
					date('m'), // endMonth
193
					date('d'), // endDay
194
					'vevent', // cType
195
					false, // flat
196
					true, // any
197
					true // split
198
				) as $year) {
199
					foreach ($year as $month => $days) {
200
						foreach ($days as $day => $events) {
201
							foreach ($events as $i => $event) {
202
203
								/* does this event already exist in Canvas? */
204
								$eventHash = getEventHash($event);
205
206
								/* if the event should be included... */
207
								if (filterEvent($event, $calendarCache)) {
208
209
									/* have we cached this event already? */
210
									$eventCacheResponse = $sql->query("
211
										SELECT *
212
											FROM `events`
213
											WHERE
214
												`calendar` = '{$calendarCache['id']}' AND
215
												`event_hash` = '$eventHash'
216
									");
217
218
219
									/* if we already have the event cached in its current form, just update
220
									   the timestamp */
221
									$eventCache = $eventCacheResponse->fetch_assoc();
222
									if ($eventCache) {
223
										$sql->query("
224
											UPDATE `events`
225
												SET
226
													`synced` = '" . getSyncTimestamp() . "'
227
												WHERE
228
													`id` = '{$eventCache['id']}'
229
										");
230
231
									/* otherwise, add this new event and cache it */
232
									} else {
233
										/* multi-day event instance start times need to be changed to _this_ date */
234
										$start = new DateTime(iCalUtilityFunctions::_date2strdate($event->getProperty('DTSTART')));
235
										$end = new DateTime(iCalUtilityFunctions::_date2strdate($event->getProperty('DTEND')));
236
										if ($event->getProperty('X-RECURRENCE')) {
237
											$start = new DateTime($event->getProperty('X-CURRENT-DTSTART')[1]);
238
											$end = new DateTime($event->getProperty('X-CURRENT-DTEND')[1]);
239
										}
240
										$start->setTimeZone(new DateTimeZone(LOCAL_TIMEZONE));
241
										$end->setTimeZone(new DateTimeZone(LOCAL_TIMEZONE));
242
243
										$calendarEvent = $api->post("/calendar_events",
244
											array(
245
												'calendar_event[context_code]' => "{$canvasContext['context']}_{$canvasObject['id']}",
246
												'calendar_event[title]' => preg_replace('%^([^\]]+)(\s*\[[^\]]+\]\s*)+$%', '\\1', strip_tags($event->getProperty('SUMMARY'))),
247
												'calendar_event[description]' => \Michelf\Markdown::defaultTransform(str_replace('\n', "\n\n", $event->getProperty('DESCRIPTION', 1))),
248
												'calendar_event[start_at]' => $start->format(CANVAS_TIMESTAMP_FORMAT),
249
												'calendar_event[end_at]' => $end->format(CANVAS_TIMESTAMP_FORMAT),
250
												'calendar_event[location_name]' => $event->getProperty('LOCATION')
251
											)
252
										);
253
254
										$sql->query("
255
											INSERT INTO `events`
256
												(
257
													`calendar`,
258
													`calendar_event[id]`,
259
													`event_hash`,
260
													`synced`
261
												)
262
												VALUES (
263
													'{$calendarCache['id']}',
264
													'{$calendarEvent['id']}',
265
													'$eventHash',
266
													'" . getSyncTimestamp() . "'
267
												)
268
										");
269
									}
270
								}
271
							}
272
						}
273
					}
274
				}
275
276
				/* clean out previously synced events that are no longer correct */
277
				$deletedEventsResponse = $sql->query("
278
					SELECT * FROM `events`
279
						WHERE
280
							`calendar` = '{$calendarCache['id']}' AND
281
							`synced` != '" . getSyncTimestamp() . "'
282
				");
283
				while ($deletedEventCache = $deletedEventsResponse->fetch_assoc()) {
284
					try {
285
						$deletedEvent = $api->delete("/calendar_events/{$deletedEventCache['calendar_event[id]']}",
286
							array(
287
								'cancel_reason' => getSyncTimestamp(),
288
								'as_user_id' => ($canvasContext['context'] == 'user' ? $canvasObject['id'] : '') // TODO: this feels skeevy -- like the empty string will break
289
							)
290
						);
291
					} catch (Pest_Unauthorized $e) {
0 ignored issues
show
Bug introduced by
The class Pest_Unauthorized does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
292
						/* if the event has been deleted in Canvas, we'll get an error when
293
						   we try to delete it a second time. We still need to delete it from
294
						   our cache database, however */
295
						postMessage('Cache out-of-sync', "calendar_event[{$deletedEventCache['calendar_event[id]']}] no longer exists and will be purged from cache.", NotificationMessage::INFO);
296
					} catch (Pest_ClientError $e) {
0 ignored issues
show
Bug introduced by
The class Pest_ClientError does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
297
						postMessage(
298
							'API Client Error',
299
							'<pre>' . print_r(array(
300
								'Status' => $PEST->lastStatus(),
301
								'Error' => $PEST->lastBody(),
302
								'Verb' => $verb,
303
								'URL' => $url,
304
								'Data' => $data
305
							), false) . '</pre>',
306
							NotificationMessage::ERROR
0 ignored issues
show
Deprecated Code introduced by
The constant Battis\BootstrapSmarty\NotificationMessage::ERROR has been deprecated with message: Use `DANGER` instead for consistency with Bootstrap

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
307
						);
308
						if (php_sapi_name() != 'cli') $smarty->display('page.tpl');
309
						exit;
310
					}
311
					$sql->query("
312
						DELETE FROM `events`
313
							WHERE
314
								`id` = '{$deletedEventCache['id']}'
315
					");
316
				}
317
318
				/* if this was a scheduled import (i.e. a sync), update that schedule */
319
				if (isset($_REQUEST['schedule'])) {
320
					$sql->query("
321
						UPDATE `schedules`
322
							SET
323
								`synced` = '" . getSyncTimestamp() . "'
324
							WHERE
325
								`id` = '{$_REQUEST['schedule']}'
326
					");
327
				}
328
				/* are we setting up a regular synchronization? */
329
				if (isset($_REQUEST['sync']) && $_REQUEST['sync'] != SCHEDULE_ONCE) {
330
331
					// FIXME:0 CRON SYNC SETUP GOES HERE issue:15 issue:13
332
333
					/* add to the cache database schedule, replacing any schedules for this
334
					   calendar that are already there */
335
					$schedulesResponse = $sql->query("
336
						SELECT *
337
							FROM `schedules`
338
							WHERE
339
								`calendar` = '{$calendarCache['id']}'
340
					");
341
342
					if ($schedule = $schedulesResponse->fetch_assoc()) {
343
344
						/* only need to worry if the cached schedule is different from the
345
						   new one we just set */
346
						if ($shellArguments[INDEX_SCHEDULE] != $schedule['schedule']) {
347
							/* was this the last schedule to require this trigger? */
348
							$schedulesResponse = $sql->query("
349
								SELECT *
350
									FROM `schedules`
351
									WHERE
352
										`calendar` != '{$calendarCache['id']}' AND
353
										`schedule` == '{$schedule['schedule']}'
354
							");
355
							/* we're the last one, delete it from crontab */
356
							if ($schedulesResponse->num_rows == 0) {
357
								$crontabs = preg_replace("%^.*{$schedule['schedule']}.*" . PHP_EOL . '%', '', shell_exec('crontab -l'));
358
								$filename = md5(getSyncTimestamp()) . '.txt';
359
								file_put_contents("/tmp/$filename", $crontabs);
360
								shell_exec("crontab /tmp/$filename");
361
								postMessage('Unused schedule', "removed schedule '{$schedule['schedule']}' from crontab", NotificationMessage::INFO);
362
							}
363
364
							$sql->query("
365
								UPDATE `schedules`
366
									SET
367
										`schedule` = '" . $shellArguments[INDEX_SCHEDULE] . "',
368
										`synced` = '" . getSyncTimestamp() . "'
369
									WHERE
370
										`calendar` = '{$calendarCache['id']}'
371
							");
372
						}
373
					} else {
374
						$sql->query("
375
							INSERT INTO `schedules`
376
								(
377
									`calendar`,
378
									`schedule`,
379
									`synced`
380
								)
381
								VALUES (
382
									'{$calendarCache['id']}',
383
									'" . $shellArguments[INDEX_SCHEDULE] . "',
384
									'" . getSyncTimestamp() . "'
385
								)
386
						");
387
					}
388
				}
389
390
				/* if we're ovewriting data (for example, if this is a recurring sync, we
391
				   need to remove the events that were _not_ synced this in this round */
392
				if (isset($_REQUEST['overwrite']) && $_REQUEST['overwrite'] == VALUE_OVERWRITE_CANVAS_CALENDAR) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
393
					// TODO: actually deal with this
394
				}
395
396
				// TODO: deal with messaging based on context
397
398
				postMessage('Finished sync', getSyncTimestamp(), NotificationMessage::INFO);
399
				exit;
400
			} else {
401
				postMessage(
402
					'Canvas Object  Not Found',
403
					'The object whose URL you submitted could not be found.<pre>' . print_r(array(
404
						'Canvas URL' => $_REQUEST['canvas_url'],
405
						'Canvas Context' => $canvasContext,
406
						'Canvas Object' => $canvasObject
407
					), false) . '</pre>',
408
					NotificationMessage::ERROR
0 ignored issues
show
Deprecated Code introduced by
The constant Battis\BootstrapSmarty\NotificationMessage::ERROR has been deprecated with message: Use `DANGER` instead for consistency with Bootstrap

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
409
				);
410
			}
411
		} else {
412
			postMessage(
413
				'ICS feed  Not Found',
414
				'The calendar whose URL you submitted could not be found.<pre>' . $_REQUEST['cal'] . '</pre>',
415
				NotificationMessage::ERROR
0 ignored issues
show
Deprecated Code introduced by
The constant Battis\BootstrapSmarty\NotificationMessage::ERROR has been deprecated with message: Use `DANGER` instead for consistency with Bootstrap

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
416
			);
417
		}
418
	} else {
419
		postMessage(
420
			'Invalid Canvas URL',
421
			'The Canvas URL you submitted could not be parsed.<pre>' . $_REQUEST['canvas_url'] . '</pre>',
422
			NotificationMessage::ERROR
0 ignored issues
show
Deprecated Code introduced by
The constant Battis\BootstrapSmarty\NotificationMessage::ERROR has been deprecated with message: Use `DANGER` instead for consistency with Bootstrap

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
423
		);
424
		if (php_sapi_name() != 'cli') $smarty->display('page.tpl');
425
		exit;
426
	}
427
}
428
429
?>
0 ignored issues
show
Best Practice introduced by
It is not recommended to use PHP's closing tag ?> in files other than templates.

Using a closing tag in PHP files that only contain PHP code is not recommended as you might accidentally add whitespace after the closing tag which would then be output by PHP. This can cause severe problems, for example headers cannot be sent anymore.

A simple precaution is to leave off the closing tag as it is not required, and it also has no negative effects whatsoever.

Loading history...
430