readMetadata.php 17.3 KB
Newer Older
haemmer's avatar
haemmer committed
1
<?php // Copyright (c) 2011, SWITCH - Serving Swiss Universities
haemmer's avatar
haemmer committed
2
3
4
5

// This file is used to dynamically create the list of IdPs to be 
// displayed for the WAYF/DS service based on the federation metadata.
// Configuration parameters are specified in config.php.
haemmer's avatar
haemmer committed
6
7
8
9
//
// The list of Identity Providers can also be updated by running the script
// readMetadata.php periodically as web server user, e.g. with a cron entry like:
// 5 * * * * /usr/bin/php readMetadata.php > /dev/null
haemmer's avatar
haemmer committed
10

11
12
13
// Init log file
openlog("SWITCHwayf.readMetadata.php", LOG_PID | LOG_PERROR, LOG_LOCAL0);

14
15
16
17
18
19
20
21
22
23
24
25
// Set configuration defaults

// Set lock file
if (!isset($metadataLockFile)){
	if(substr($_SERVER['PATH'],0,1) == '/'){
		$metadataLockFile = '/tmp/wayf_metadata.lock';
	} else {
		$metadataLockFile = 'C:\windows\TEMP';
	}
}


haemmer's avatar
haemmer committed
26
// Make sure this script is not accessed directly
27
if(isRunViaCLI()){
haemmer's avatar
haemmer committed
28
29
30
	// Run in cli mode.
	// Could be used for testing purposes or to facilitate startup confiduration.
	// Results are dumped in $metadataIDPFile (see config.php)
31
32
33
34
35
	
	// Set dummy server name
	$_SERVER['SERVER_NAME'] = 'localhost';
	
	// Load configuration files
haemmer's avatar
haemmer committed
36
37
38
39
40
41
42
43
44
	require('config.php');
	require($IDPConfigFile);
	
	if (
		!isset($metadataFile) 
		|| !file_exists($metadataFile) 
		|| trim(@file_get_contents($metadataFile)) == '') {
	  exit ("Exiting: File ".$metadataFile." is empty or does not exist\n");
	}
45
	
46
47
48
49
50
51
52
	// Check configuration
	if (!isset($metadataSPFile)){
		$errorMsg = 'Please first define a file $metadataSPFile = \'SProvider.metadata.conf.php\'; in config.php before running this script.';
		syslog(LOG_ERR, $errorMsg);
		die($errorMsg);
	}
	
53
54
55
56
57
58
59
60
61
62
	// Get an exclusive lock to generate our parsed IdP and SP files.
	if (($lockFp = fopen($metadataLockFile, 'a+')) === false) {
		$errorMsg = 'Could not open lock file '.$metadataLockFile;
		die($errorMsg);
	}
	if (flock($lockFp, LOCK_EX) === false) { 
		$errorMsg = 'Could not lock file '.$metadataLockFile;
		die($errorMsg);
	}
	
63
64
	echo 'Parsing metadata file '.$metadataFile."\n";
	list($metadataIDProviders, $metadataSProviders) = parseMetadata($metadataFile, $defaultLanguage);
haemmer's avatar
haemmer committed
65
	
66
	// If $metadataIDProviders is not FALSE, dump results in $metadataIDPFile.
haemmer's avatar
haemmer committed
67
68
	if(is_array($metadataIDProviders)){ 
		
69
70
		echo 'Dumping parsed Identity Providers to file '.$metadataIDPFile."\n";
		dumpFile($metadataIDPFile, $metadataIDProviders, 'metadataIDProviders');
71
72
73
	}
	// If $metadataSProviders is not FALSE, dump results in $metadataSPFile.
	if(is_array($metadataSProviders)){ 
haemmer's avatar
haemmer committed
74
		
75
76
77
78
79
80
81
82
83
84
85
		echo 'Dumping parsed Service Providers to file '.$metadataSPFile."\n";
		dumpFile($metadataSPFile, $metadataSProviders, 'metadataSProviders');
	}

	// Release the lock, and close.
	flock($lockFp, LOCK_UN);
	fclose($lockFp);
		
	// If $metadataIDProviders is not FALSE, update $IDProviders and print the Identity Providers lists.
	if(is_array($metadataIDProviders)){ 

86
		echo 'Merging parsed Identity Providers with data from file '.$IDProviders."\n";
haemmer's avatar
haemmer committed
87
88
89
		$IDProviders = mergeInfo($IDProviders, $metadataIDProviders, $SAML2MetaOverLocalConf, $includeLocalConfEntries);
		
		echo "Printing parsed Identity Providers:\n";
90
91
92
		print_r($metadataIDProviders);
		
		echo "Printing effective Identity Providers:\n";
haemmer's avatar
haemmer committed
93
94
95
		print_r($IDProviders);
	}
	
96
	// If $metadataSProviders is not FALSE, update $SProviders and print the list.
97
98
99
100
101
102
103
104
105
	if(is_array($metadataSProviders)){ 
		
		// Fow now copy the array by reference
		$SProviders = &$metadataSProviders;
		
		echo "Printing parsed Service Providers:\n";
		print_r($metadataSProviders);
	}
	
haemmer's avatar
haemmer committed
106
	
107
} elseif (isRunViaInclude()) {
108
109
110
111
112
113
114
115
	
	// Check configuration
	if (!isset($metadataSPFile)){
		$errorMsg = 'Please first define a file $metadataSPFile = \'SProvider.metadata.conf.php\'; in config.php before running this script.';
		syslog(LOG_ERR, $errorMsg);
		die($errorMsg);
	}
	
haemmer's avatar
haemmer committed
116
117
118
119
120
121
	if (!isset($metadataFile)){
		$errorMsg = 'Please first define a file $metadataFile in config.php before running this script.';
		syslog(LOG_ERR, $errorMsg);
		die($errorMsg);
	}
	
122
123
124
125
126
127
	// Open the metadata lock file.
	if (($lockFp = fopen($metadataLockFile, 'a+')) === false) {
		$errorMsg = 'Could not open lock file '.$metadataLockFile;
		syslog(LOG_ERR, $errorMsg);
	}

128
	// Run as included file
haemmer's avatar
haemmer committed
129
	if(!file_exists($metadataIDPFile) or filemtime($metadataFile) > filemtime($metadataIDPFile)){
130
131
132
133
134
135
136
		// Get an exclusive lock to regenerate the parsed files.
		if ($lockFp !== false) {
			if (flock($lockFp, LOCK_EX) === false) { 
				$errorMsg = 'Could not get exclusive lock on '.$metadataLockFile;
				syslog(LOG_ERR, $errorMsg);
			}
		}
haemmer's avatar
haemmer committed
137
		// Regenerate $metadataIDPFile.
138
		list($metadataIDProviders, $metadataSProviders) = parseMetadata($metadataFile, $defaultLanguage);
haemmer's avatar
haemmer committed
139
140
141
142
		
		// If $metadataIDProviders is not an array (parse error in metadata),
		// $IDProviders from $IDPConfigFile will be used.
		if(is_array($metadataIDProviders)){
143
			dumpFile($metadataIDPFile, $metadataIDProviders, 'metadataIDProviders');
haemmer's avatar
haemmer committed
144
145
			$IDProviders = mergeInfo($IDProviders, $metadataIDProviders, $SAML2MetaOverLocalConf, $includeLocalConfEntries);
		}
146
147
148
149
150
151
		
		if(is_array($metadataSProviders)){
			dumpFile($metadataSPFile, $metadataSProviders, 'metadataSProviders');
			require($metadataSPFile);
		}
		
152
153
154
155
156
		// Release the lock.
		if ($lockFp !== false) {
			flock($lockFp, LOCK_UN);
		}

157
158
159
160
161
162
				// Now merge IDPs from metadata and static file
		$IDProviders = mergeInfo($IDProviders, $metadataIDProviders, $SAML2MetaOverLocalConf, $includeLocalConfEntries);
		
		// Fow now copy the array by reference
		$SProviders = &$metadataSProviders;
		
haemmer's avatar
haemmer committed
163
164
	} elseif (file_exists($metadataIDPFile)){
		
165
166
167
168
169
170
171
172
173
		// Get a shared lock to read the IdP and SP files
		// generated from the metadata file.
		if ($lockFp !== false) {
			if (flock($lockFp, LOCK_SH) === false) { 
				$errorMsg = 'Could not lock file '.$metadataLockFile;
				syslog(LOG_ERR, $errorMsg);
			}
		}

174
		// Read SP and IDP files generated with metadata
haemmer's avatar
haemmer committed
175
		require($metadataIDPFile);
176
		require($metadataSPFile);
haemmer's avatar
haemmer committed
177
	
178
179
180
181
182
		// Release the lock.
		if ($lockFp !== false) {
			flock($lockFp, LOCK_UN);
		}

183
184
185
186
187
188
		// Now merge IDPs from metadata and static file
		$IDProviders = mergeInfo($IDProviders, $metadataIDProviders, $SAML2MetaOverLocalConf, $includeLocalConfEntries);
		
		// Fow now copy the array by reference
		$SProviders = &$metadataSProviders;
	}
189
190
191
192
193

	// Close the metadata lock file.
	if ($lockFp !== false) {
		fclose($lockFp);
	}
194
	
haemmer's avatar
haemmer committed
195
196
197
198
} else {
	exit('No direct script access allowed');
}

haemmer's avatar
haemmer committed
199
200
closelog();

haemmer's avatar
haemmer committed
201
/*****************************************************************************/
202
203
// Function parseMetadata, parses metadata file and returns Array($IdPs, SPs)  or
// Array(false, false) if error occurs while parsing metadata file
haemmer's avatar
haemmer committed
204
205
function parseMetadata($metadataFile, $defaultLanguage){
	
206
	if(!file_exists($metadataFile)){
haemmer's avatar
haemmer committed
207
		$errorMsg = 'File '.$metadataFile." does not exist"; 
208
		if (isRunViaCLI()){
haemmer's avatar
haemmer committed
209
			echo $errorMsg."\n";
210
211
212
213
214
215
216
		} else {
			syslog(LOG_ERR, $errorMsg);
		}
		return Array(false, false);
	}

	if(!is_readable($metadataFile)){
haemmer's avatar
haemmer committed
217
218
219
220
221
222
		$errorMsg = 'File '.$metadataFile." cannot be read due to insufficient permissions"; 
		if (isRunViaCLI()){
			echo $errorMsg."\n";
		} else {
			syslog(LOG_ERR, $errorMsg);
		}
223
224
		return Array(false, false);
	}
haemmer's avatar
haemmer committed
225
	
226
227
	$doc = new DOMDocument();
	if(!$doc->load( $metadataFile )){
haemmer's avatar
haemmer committed
228
229
230
231
232
233
		$errorMsg = 'Could not parse metadata file '.$metadataFile; 
		if (isRunViaCLI()){
			echo $errorMsg."\n";
		} else {
			syslog(LOG_ERR, $errorMsg);
		}
234
235
236
237
238
239
240
241
242
		return Array(false, false);
	}
	
	$EntityDescriptors = $doc->getElementsByTagNameNS( 'urn:oasis:names:tc:SAML:2.0:metadata', 'EntityDescriptor' );
	
	$metadataIDProviders = Array();
	$metadataSProviders = Array();
	foreach( $EntityDescriptors as $EntityDescriptor ){
		$entityID = $EntityDescriptor->getAttribute('entityID');
haemmer's avatar
haemmer committed
243
		
244
		foreach($EntityDescriptor->childNodes as $RoleDescriptor) {
245
			$nodeName = $RoleDescriptor->localName;
246
 			switch($nodeName){
247
248
249
250
				case 'IDPSSODescriptor':
					$IDP = processIDPRoleDescriptor($RoleDescriptor);
					if ($IDP){
						$metadataIDProviders[$entityID] = $IDP;
haemmer's avatar
haemmer committed
251
					}
252
253
254
255
256
					break;
				case 'SPSSODescriptor':
					$SP = processSPRoleDescriptor($RoleDescriptor);
					if ($SP){
						$metadataSProviders[$entityID] = $SP;
257
258
					} else {
						$errorMsg = "Failed to load SP with entityID $entityID from metadata file $metadataFile";
haemmer's avatar
haemmer committed
259
260
261
262
263
						if (isRunViaCLI()){
							echo $errorMsg."\n";
						} else {
							syslog(LOG_WARNING, $errorMsg);
						}
264
					}
265
266
					break;
				default:
haemmer's avatar
haemmer committed
267
268
269
270
			}
		}
	}
	
haemmer's avatar
haemmer committed
271
272
273
274
275
276
277
278
279
280
	
	// Output result
	$infoMsg = "Successfully parsed metadata file ".$metadataFile. ". Found ".count($metadataIDProviders)." IdPs and ".count($metadataSProviders)." SPs";
	if (isRunViaCLI()){
		echo $infoMsg."\n";
	} else {
		syslog(LOG_INFO, $infoMsg);
	}
	
	
281
	return Array($metadataIDProviders, $metadataSProviders);
haemmer's avatar
haemmer committed
282
283
284
}

/******************************************************************************/
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
// Is this script run in CLI mode
function isRunViaCLI(){
	return !isset($_SERVER['REMOTE_ADDR']);
}

/******************************************************************************/
// Is this script run in CLI mode
function isRunViaInclude(){
	return basename($_SERVER['SCRIPT_NAME']) != 'readMetadata.php';
}

/******************************************************************************/
// Processes an IDPRoleDescriptor XML node and returns an IDP entry or false if 
// something went wrong
function processIDPRoleDescriptor($IDPRoleDescriptorNode){
haemmer's avatar
haemmer committed
300
301
	global $defaultLanguage;
	
302
303
304
305
306
307
308
	$IDP = Array();
	
	// Get SSO URL
	$SSOServices = $IDPRoleDescriptorNode->getElementsByTagNameNS( 'urn:oasis:names:tc:SAML:2.0:metadata', 'SingleSignOnService' );
	foreach( $SSOServices as $SSOService ){
		if ($SSOService->getAttribute('Binding') == 'urn:mace:shibboleth:1.0:profiles:AuthnRequest'){
			$IDP['SSO'] =  $SSOService->getAttribute('Location');
309
310
311
312
			break;
		} else if ($SSOService->getAttribute('Binding') == 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'){
			$IDP['SSO'] =  $SSOService->getAttribute('Location');
			break;
313
314
315
316
		}
	}
	
	if (!isset($IDP['SSO'])){
317
		$IDP['SSO'] = 'https://no.saml1.or.saml2.sso.url.defined.com/error';
318
319
	}
	
320
321
322
323
324
325
326
327
	// First get MDUI name
	$MDUIDisplayNames = getMDUIDisplayNames($IDPRoleDescriptorNode);
	if (count($MDUIDisplayNames)){
		$IDP['Name'] = current($MDUIDisplayNames);
	}
	foreach ($MDUIDisplayNames as $lang => $value){
		$IDP[$lang]['Name'] = $value;
	}
328
	
329
330
331
332
	// Then try organization names 
	if (empty($IDP['Name'])){
		$OrgnizationNames = getOrganizationNames($IDPRoleDescriptorNode);
		$IDP['Name'] = current($OrgnizationNames);
haemmer's avatar
haemmer committed
333
		
334
335
		foreach ($OrgnizationNames as $lang => $value){
			$IDP[$lang]['Name'] = $value;
336
		}
337
338
339
340
	} 
	
	// As last resort, use entityID
	if (empty($IDP['Name'])){
341
342
343
		$IDP['Name'] = $IDPRoleDescriptorNode->parentNode->getAttribute('entityID');
	}
	
344
345
346
347
348
349
350
351
352
353
354
	// Set default name
	if (isset($IDP[$defaultLanguage])){
		$IDP['Name'] = $IDP[$defaultLanguage]['Name'];
	} elseif (isset($IDP['en'])){
		$IDP['Name'] = $IDP['en']['Name'];
	}
	
	// Get supported protocols
	$protocols = $IDPRoleDescriptorNode->getAttribute('protocolSupportEnumeration');
	$IDP['Protocols'] = $protocols;
	
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
	return $IDP;
}

/******************************************************************************/
// Processes an SPRoleDescriptor XML node and returns an SP entry or false if 
// something went wrong
function processSPRoleDescriptor($SPRoleDescriptorNode){
	$SP = Array();
	
	// Get <idpdisc:DiscoveryResponse> extensions
	$DResponses = $SPRoleDescriptorNode->getElementsByTagNameNS('urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol', 'DiscoveryResponse');
	foreach( $DResponses as $DResponse ){
		if ($DResponse->getAttribute('Binding') == 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'){
			$SP['DSURL'][] =  $DResponse->getAttribute('Location');
		}
	}
	
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
	// First get MDUI name
	$MDUIDisplayNames = getMDUIDisplayNames($SPRoleDescriptorNode);
	if (count($MDUIDisplayNames)){
		$SP['Name'] = current($MDUIDisplayNames);
	}
	foreach ($MDUIDisplayNames as $lang => $value){
		$SP[$lang]['Name'] = $value;
	}
	
	// Then try attribute consuming service
	if (empty($SP['Name'])){
		$ConsumingServiceNames = getAttributeConsumingServiceNames($SPRoleDescriptorNode);
		$SP['Name'] = current($ConsumingServiceNames);
		
		foreach ($ConsumingServiceNames as $lang => $value){
			$SP[$lang]['Name'] = $value;
		}
	} 
	
	// As last resort, use entityID
	if (empty($SP['Name'])){
		$SP['Name'] = $SPRoleDescriptorNode->parentNode->getAttribute('entityID');
	}
	
	// Set default name
	if (isset($SP[$defaultLanguage])){
		$SP['Name'] = $SP[$defaultLanguage]['Name'];
	} elseif (isset($SP['en'])){
		$SP['Name'] = $SP['en']['Name'];
	}
	
403
404
405
406
407
408
	// Get Assertion Consumer Services and store their hostnames
	$ACServices = $SPRoleDescriptorNode->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'AssertionConsumerService');
	foreach( $ACServices as $ACService ){
		$SP['ACURL'][] =  $ACService->getAttribute('Location');
	}
	
409
410
411
412
	// Get supported protocols
	$protocols = $SPRoleDescriptorNode->getAttribute('protocolSupportEnumeration');
	$SP['Protocols'] = $protocols;
	
413
414
415
416
417
418
419
420
	return $SP;
}

/******************************************************************************/
// Dump variable to a file 
function dumpFile($dumpFile, $providers, $variableName){
	 
	if(($fp = fopen($dumpFile, 'w')) !== false){
haemmer's avatar
haemmer committed
421
		
422
423
424
		fwrite($fp, "<?php\n\n");
		fwrite($fp, "// This file was automatically generated by readMetadata.php\n");
		fwrite($fp, "// Don't edit!\n\n");
haemmer's avatar
haemmer committed
425
		
426
427
428
429
430
		fwrite($fp, '$'.$variableName.' = ');
		fwrite($fp, var_export($providers,true));
		
		fwrite($fp, "\n?>");
			
431
		fclose($fp);
haemmer's avatar
haemmer committed
432
	} else {
haemmer's avatar
haemmer committed
433
434
435
436
437
438
		$errorMsg = 'Could not open file '.$dumpFile.' for writting';
		if (isRunViaCLI()){
			echo $errorMsg."\n";
		} else {
			syslog(LOG_ERR, $errorMsg);
		}
haemmer's avatar
haemmer committed
439
440
441
442
443
444
445
446
447
448
449
450
451
452
	}
}


/******************************************************************************/
// Function mergeInfo is used to create the effective $IDProviders array.
// For each IDP found in the metadata, merge the values from IDProvider.conf.php.
// If an IDP is found in IDProvider.conf as well as in metadata, use metadata  
// information if $SAML2MetaOverLocalConf is true or else use IDProvider.conf data
function mergeInfo($IDProviders, $metadataIDProviders, $SAML2MetaOverLocalConf, $includeLocalConfEntries){

	// If $includeLocalConfEntries parameter is set to true, mergeInfo() will also consider IDPs
	// not listed in metadataIDProviders but defined in IDProviders file
	// This is required if you need to add local exceptions over the federation metadata
453
454
	$allIDPS = $metadataIDProviders;
	$mergedArray = Array();
haemmer's avatar
haemmer committed
455
	if ($includeLocalConfEntries) {
456
		  $allIDPS = array_merge($metadataIDProviders, $IDProviders);
haemmer's avatar
haemmer committed
457
	}
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
	
	foreach ($allIDPS as $allIDPsKey => $allIDPsEntry){
		if(isset($IDProviders[$allIDPsKey])){
			// Entry exists also in local IDProviders.conf.php
			if (isset($metadataIDProviders[$allIDPsKey]) && is_array($metadataIDProviders[$allIDPsKey])) {
				
				// Remove IdP if there is a removal rule in local IDProviders.conf.php 
				if (!is_array($IDProviders[$allIDPsKey])){
					unset($metadataIDProviders[$allIDPsKey]);
					continue;
				}
				
				// Entry exists in both IDProviders sources and is an array
				if($SAML2MetaOverLocalConf){
					// Metadata entry overwrite local conf
					$mergedArray[$allIDPsKey] = array_merge($IDProviders[$allIDPsKey], $metadataIDProviders[$allIDPsKey]);
				} else {
					// Local conf overwrites metada entry
					$mergedArray[$allIDPsKey] = array_merge($metadataIDProviders[$allIDPsKey], $IDProviders[$allIDPsKey]);
				}
			  } else {
					// Entry only exists in local IDProviders file
					$mergedArray[$allIDPsKey] = $IDProviders[$allIDPsKey];
			  }
haemmer's avatar
haemmer committed
482
		} else {
483
484
			// Entry doesnt exist in in local IDProviders.conf.php
			$mergedArray[$allIDPsKey] = $metadataIDProviders[$allIDPsKey];
haemmer's avatar
haemmer committed
485
486
487
488
489
490
		}
	}
	
	return $mergedArray;
}

491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
/******************************************************************************/
// Get MD Display Names from RoleDescriptor
function getMDUIDisplayNames($RoleDescriptorNode){
	
	$Entity = Array();
	
	$MDUIDisplayNames = $RoleDescriptorNode->getElementsByTagNameNS('urn:oasis:names:tc:SAML:metadata:ui', 'DisplayName');
	foreach( $MDUIDisplayNames as $MDUIDisplayName ){
		$lang = $MDUIDisplayName->getAttributeNodeNS('http://www.w3.org/XML/1998/namespace', 'lang')->nodeValue;
		$Entity[$lang] = $MDUIDisplayName->nodeValue;
	}
	
	return $Entity;
}

/******************************************************************************/
// Get Organization Names from RoleDescriptor
function getOrganizationNames($RoleDescriptorNode){
	
	$Entity = Array();
	
	$Orgnization = $RoleDescriptorNode->parentNode->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'Organization' )->item(0);
	if ($Orgnization){
		$DisplayNames = $Orgnization->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'OrganizationDisplayName');
		foreach ($DisplayNames as $DisplayName){
			$lang = $DisplayName->getAttributeNodeNS('http://www.w3.org/XML/1998/namespace', 'lang')->nodeValue;
			$Entity[$lang] = $DisplayName->nodeValue;
		}
	}
	
	return $Entity;
}


/******************************************************************************/
// Get Organization Names from RoleDescriptor
function getAttributeConsumingServiceNames($RoleDescriptorNode){
	
	$Entity = Array();
	
	$ServiceDescriptions = $RoleDescriptorNode->getElementsByTagNameNS('urn:oasis:names:tc:SAML:2.0:metadata', 'ServiceDescription' );
	foreach ($ServiceDescriptions as $ServiceDescription){
		$lang = $ServiceDescription->getAttributeNodeNS('http://www.w3.org/XML/1998/namespace', 'lang')->nodeValue;
		$Entity[$lang] = $ServiceDescription->nodeValue;
	}
	
	return $Entity;
}

haemmer's avatar
haemmer committed
540
?>