Replace spaces with tabs.

pull/1111/head
very-ape 2021-05-17 10:34:04 -07:00
parent 72ed9efc2e
commit aa2992adb1
1 changed files with 318 additions and 318 deletions

View File

@ -23,60 +23,60 @@ define("PW_LEN", 32); // number of characters to use for random passwords
function saml_module($a) {} function saml_module($a) {}
function saml_init($a) { function saml_init($a) {
if ($a->argc < 2) return; if ($a->argc < 2) return;
switch ($a->argv[1]) { switch ($a->argv[1]) {
case "metadata.xml": case "metadata.xml":
saml_metadata(); saml_metadata();
break; break;
case "sso": case "sso":
saml_sso_reply($a); saml_sso_reply($a);
break; break;
case "slo": case "slo":
saml_slo_reply(); saml_slo_reply();
break; break;
case "moo": case "moo":
echo DI::baseUrl(); echo DI::baseUrl();
echo $_SERVER['REQUEST_URI']; echo $_SERVER['REQUEST_URI'];
break; break;
} }
exit(); exit();
} }
function saml_metadata() { function saml_metadata() {
try { try {
$settings = new \OneLogin\Saml2\Settings(saml_settings()); $settings = new \OneLogin\Saml2\Settings(saml_settings());
$metadata = $settings->getSPMetadata(); $metadata = $settings->getSPMetadata();
$errors = $settings->validateMetadata($metadata); $errors = $settings->validateMetadata($metadata);
if (empty($errors)) { if (empty($errors)) {
header('Content-Type: text/xml'); header('Content-Type: text/xml');
echo $metadata; echo $metadata;
} else { } else {
throw new \OneLogin\Saml2\Error( throw new \OneLogin\Saml2\Error(
'Invalid SP metadata: '.implode(', ', $errors), 'Invalid SP metadata: '.implode(', ', $errors),
\OneLogin\Saml2\Error::METADATA_SP_INVALID \OneLogin\Saml2\Error::METADATA_SP_INVALID
); );
} }
} catch (Exception $e) { } catch (Exception $e) {
Logger::error($e->getMessage()); Logger::error($e->getMessage());
} }
} }
function saml_install() { function saml_install() {
Hook::register('login_hook', __FILE__, 'saml_sso_initiate'); Hook::register('login_hook', __FILE__, 'saml_sso_initiate');
Hook::register('logging_out', __FILE__, 'saml_slo_initiate'); Hook::register('logging_out', __FILE__, 'saml_slo_initiate');
Hook::register('head', __FILE__, 'saml_head'); Hook::register('head', __FILE__, 'saml_head');
Hook::register('footer', __FILE__, 'saml_footer'); Hook::register('footer', __FILE__, 'saml_footer');
} }
function saml_head(&$a, &$b) { function saml_head(&$a, &$b) {
DI::page()->registerStylesheet(__DIR__ . '/saml.css'); DI::page()->registerStylesheet(__DIR__ . '/saml.css');
} }
function saml_footer(&$a, &$b) { function saml_footer(&$a, &$b) {
$fragment = addslashes(BBCode::convert(DI::config()->get('saml', 'settings_statement'))); $fragment = addslashes(BBCode::convert(DI::config()->get('saml', 'settings_statement')));
$b .= <<<EOL $b .= <<<EOL
<script> <script>
var target=$("#settings-nickname-desc"); var target=$("#settings-nickname-desc");
if (target.length) { target.append("<p>$fragment</p>"); } if (target.length) { target.append("<p>$fragment</p>"); }
@ -85,346 +85,346 @@ EOL;
} }
function saml_is_configured() { function saml_is_configured() {
return return
DI::config()->get('saml', 'idp_id') && DI::config()->get('saml', 'idp_id') &&
DI::config()->get('saml', 'client_id') && DI::config()->get('saml', 'client_id') &&
DI::config()->get('saml', 'sso_url') && DI::config()->get('saml', 'sso_url') &&
DI::config()->get('saml', 'slo_request_url') && DI::config()->get('saml', 'slo_request_url') &&
DI::config()->get('saml', 'slo_response_url') && DI::config()->get('saml', 'slo_response_url') &&
DI::config()->get('saml', 'sp_key') && DI::config()->get('saml', 'sp_key') &&
DI::config()->get('saml', 'sp_cert') && DI::config()->get('saml', 'sp_cert') &&
DI::config()->get('saml', 'idp_cert'); DI::config()->get('saml', 'idp_cert');
} }
function saml_sso_initiate(&$a, &$b) { function saml_sso_initiate(&$a, &$b) {
if (!saml_is_configured()) return; if (!saml_is_configured()) return;
$auth = new \OneLogin\Saml2\Auth(saml_settings()); $auth = new \OneLogin\Saml2\Auth(saml_settings());
$ssoBuiltUrl = $auth->login(null, array(), false, false, true); $ssoBuiltUrl = $auth->login(null, array(), false, false, true);
$_SESSION['AuthNRequestID'] = $auth->getLastRequestID(); $_SESSION['AuthNRequestID'] = $auth->getLastRequestID();
header('Pragma: no-cache'); header('Pragma: no-cache');
header('Cache-Control: no-cache, must-revalidate'); header('Cache-Control: no-cache, must-revalidate');
header('Location: ' . $ssoBuiltUrl); header('Location: ' . $ssoBuiltUrl);
exit(); exit();
} }
function saml_sso_reply($a) { function saml_sso_reply($a) {
$auth = new \OneLogin\Saml2\Auth(saml_settings()); $auth = new \OneLogin\Saml2\Auth(saml_settings());
$requestID = null; $requestID = null;
if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) { if (isset($_SESSION) && isset($_SESSION['AuthNRequestID'])) {
$requestID = $_SESSION['AuthNRequestID']; $requestID = $_SESSION['AuthNRequestID'];
} }
$auth->processResponse($requestID); $auth->processResponse($requestID);
unset($_SESSION['AuthNRequestID']); unset($_SESSION['AuthNRequestID']);
$errors = $auth->getErrors(); $errors = $auth->getErrors();
if (!empty($errors)) { if (!empty($errors)) {
echo "Errors encountered."; echo "Errors encountered.";
Logger::error(implode(', ', $errors)); Logger::error(implode(', ', $errors));
exit(); exit();
} }
if (!$auth->isAuthenticated()) { if (!$auth->isAuthenticated()) {
echo "Not authenticated"; echo "Not authenticated";
exit(); exit();
} }
$username = $auth->getNameId(); $username = $auth->getNameId();
$email = $auth->getAttributeWithFriendlyName('email')[0]; $email = $auth->getAttributeWithFriendlyName('email')[0];
$name = $auth->getAttributeWithFriendlyName('givenName')[0]; $name = $auth->getAttributeWithFriendlyName('givenName')[0];
$last_name = $auth->getAttributeWithFriendlyName('surname')[0]; $last_name = $auth->getAttributeWithFriendlyName('surname')[0];
if (strlen($last_name)) { if (strlen($last_name)) {
$name .= " $last_name"; $name .= " $last_name";
} }
if (!DBA::exists('user', ['nickname' => $username])) { if (!DBA::exists('user', ['nickname' => $username])) {
$user = saml_create_user($username, $email, $name); $user = saml_create_user($username, $email, $name);
} else { } else {
$user = User::getByNickname($username); $user = User::getByNickname($username);
} }
if (!empty($user['uid'])) { if (!empty($user['uid'])) {
DI::auth()->setForUser($a, $user); DI::auth()->setForUser($a, $user);
} }
if (isset($_POST['RelayState']) if (isset($_POST['RelayState'])
&& \OneLogin\Saml2\Utils::getSelfURL() != $_POST['RelayState']) && \OneLogin\Saml2\Utils::getSelfURL() != $_POST['RelayState'])
{ {
$auth->redirectTo($_POST['RelayState']); $auth->redirectTo($_POST['RelayState']);
} }
} }
function saml_slo_initiate(&$a, &$b) { function saml_slo_initiate(&$a, &$b) {
$auth = new \OneLogin\Saml2\Auth(saml_settings()); $auth = new \OneLogin\Saml2\Auth(saml_settings());
$sloBuiltUrl = $auth->logout(); $sloBuiltUrl = $auth->logout();
$_SESSION['LogoutRequestID'] = $auth->getLastRequestID(); $_SESSION['LogoutRequestID'] = $auth->getLastRequestID();
header('Pragma: no-cache'); header('Pragma: no-cache');
header('Cache-Control: no-cache, must-revalidate'); header('Cache-Control: no-cache, must-revalidate');
header('Location: ' . $sloBuiltUrl); header('Location: ' . $sloBuiltUrl);
exit(); exit();
} }
function saml_slo_reply() { function saml_slo_reply() {
$auth = new \OneLogin\Saml2\Auth(saml_settings()); $auth = new \OneLogin\Saml2\Auth(saml_settings());
if (isset($_SESSION) && isset($_SESSION['LogoutRequestID'])) { if (isset($_SESSION) && isset($_SESSION['LogoutRequestID'])) {
$requestID = $_SESSION['LogoutRequestID']; $requestID = $_SESSION['LogoutRequestID'];
} else { } else {
$requestID = null; $requestID = null;
} }
$auth->processSLO(false, $requestID); $auth->processSLO(false, $requestID);
$errors = $auth->getErrors(); $errors = $auth->getErrors();
if (empty($errors)) { if (empty($errors)) {
$auth->redirectTo(DI::baseUrl()); $auth->redirectTo(DI::baseUrl());
} else { } else {
Logger::error(implode(', ', $errors)); Logger::error(implode(', ', $errors));
} }
} }
function saml_input($key, $label, $description) { function saml_input($key, $label, $description) {
return [ return [
'$' . $key => [ '$' . $key => [
$key, $key,
$label, $label,
DI::config()->get('saml', $key), DI::config()->get('saml', $key),
$description, $description,
true, // all the fields are required true, // all the fields are required
] ]
]; ];
} }
function saml_addon_admin (&$a, &$o) { function saml_addon_admin (&$a, &$o) {
$form = $form =
saml_input( saml_input(
'settings_statement', 'settings_statement',
DI::l10n()->t('Settings statement'), DI::l10n()->t('Settings statement'),
DI::l10n()->t('A statement on the settings page explaining where the user should go to change their e-mail and password. BBCode allowed.') DI::l10n()->t('A statement on the settings page explaining where the user should go to change their e-mail and password. BBCode allowed.')
) + ) +
saml_input( saml_input(
'idp_id', 'idp_id',
DI::l10n()->t('IdP ID'), DI::l10n()->t('IdP ID'),
DI::l10n()->t('Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).') DI::l10n()->t('Identity provider (IdP) entity URI (e.g., https://example.com/auth/realms/user).')
) + ) +
saml_input( saml_input(
'client_id', 'client_id',
DI::l10n()->t('Client ID'), DI::l10n()->t('Client ID'),
DI::l10n()->t('Identifier assigned to client by the identity provider (IdP).') DI::l10n()->t('Identifier assigned to client by the identity provider (IdP).')
) + ) +
saml_input( saml_input(
'sso_url', 'sso_url',
DI::l10n()->t('IdP SSO URL'), DI::l10n()->t('IdP SSO URL'),
DI::l10n()->t('The URL for your identity provider\'s SSO endpoint.') DI::l10n()->t('The URL for your identity provider\'s SSO endpoint.')
) + ) +
saml_input( saml_input(
'slo_request_url', 'slo_request_url',
DI::l10n()->t('IdP SLO request URL'), DI::l10n()->t('IdP SLO request URL'),
DI::l10n()->t('The URL for your identity provider\'s SLO request endpoint.') DI::l10n()->t('The URL for your identity provider\'s SLO request endpoint.')
) + ) +
saml_input( saml_input(
'slo_response_url', 'slo_response_url',
DI::l10n()->t('IdP SLO response URL'), DI::l10n()->t('IdP SLO response URL'),
DI::l10n()->t('The URL for your identity provider\'s SLO response endpoint.') DI::l10n()->t('The URL for your identity provider\'s SLO response endpoint.')
) + ) +
saml_input( saml_input(
'sp_key', 'sp_key',
DI::l10n()->t('SP private key'), DI::l10n()->t('SP private key'),
DI::l10n()->t('The private key the addon should use to authenticate.') DI::l10n()->t('The private key the addon should use to authenticate.')
) + ) +
saml_input( saml_input(
'sp_cert', 'sp_cert',
DI::l10n()->t('SP certificate'), DI::l10n()->t('SP certificate'),
DI::l10n()->t('The certficate for the addon\'s private key.') DI::l10n()->t('The certficate for the addon\'s private key.')
) + ) +
saml_input( saml_input(
'idp_cert', 'idp_cert',
DI::l10n()->t('IdP certificate'), DI::l10n()->t('IdP certificate'),
DI::l10n()->t('The x509 certficate for your identity provider.') DI::l10n()->t('The x509 certficate for your identity provider.')
) + ) +
[ [
'$submit' => DI::l10n()->t('Save Settings'), '$submit' => DI::l10n()->t('Save Settings'),
]; ];
$t = Renderer::getMarkupTemplate( "admin.tpl", "addon/saml/" ); $t = Renderer::getMarkupTemplate( "admin.tpl", "addon/saml/" );
$o = Renderer::replaceMacros( $t, $form); $o = Renderer::replaceMacros( $t, $form);
} }
function saml_addon_admin_post (&$a) { function saml_addon_admin_post (&$a) {
$safeset = function ($key) { $safeset = function ($key) {
$val = (!empty($_POST[$key]) ? Strings::escapeTags(trim($_POST[$key])) : ''); $val = (!empty($_POST[$key]) ? Strings::escapeTags(trim($_POST[$key])) : '');
DI::config()->set('saml', $key, $val); DI::config()->set('saml', $key, $val);
}; };
$safeset('idp_id'); $safeset('idp_id');
$safeset('client_id'); $safeset('client_id');
$safeset('sso_url'); $safeset('sso_url');
$safeset('slo_request_url'); $safeset('slo_request_url');
$safeset('slo_response_url'); $safeset('slo_response_url');
$safeset('sp_key'); $safeset('sp_key');
$safeset('sp_cert'); $safeset('sp_cert');
$safeset('idp_cert'); $safeset('idp_cert');
// Not using safeset here since settings_statement is *meant* to include HTML tags. // Not using safeset here since settings_statement is *meant* to include HTML tags.
DI::config()->set('saml', 'settings_statement', $_POST['settings_statement']); DI::config()->set('saml', 'settings_statement', $_POST['settings_statement']);
} }
function saml_create_user($username, $email, $name) { function saml_create_user($username, $email, $name) {
if (!strlen($email) || !strlen($name)) { if (!strlen($email) || !strlen($name)) {
Logger::error('Could not create user: no email or username given.'); Logger::error('Could not create user: no email or username given.');
return false; return false;
} }
try { try {
$strong = false; $strong = false;
$bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong); $bytes = openssl_random_pseudo_bytes(intval(ceil(PW_LEN * 0.75)), $strong);
if (!$strong) { if (!$strong) {
throw new Exception('Strong algorithm not available for PRNG.'); throw new Exception('Strong algorithm not available for PRNG.');
} }
$user = User::create([ $user = User::create([
'username' => $name, 'username' => $name,
'nickname' => $username, 'nickname' => $username,
'email' => $email, 'email' => $email,
'password' => base64_encode($bytes), // should be at least PW_LEN long 'password' => base64_encode($bytes), // should be at least PW_LEN long
'verified' => true 'verified' => true
]); ]);
return $user; return $user;
} catch (Exception $e) { } catch (Exception $e) {
Logger::error( Logger::error(
'Exception while creating user', 'Exception while creating user',
[ [
'username' => $username, 'username' => $username,
'email' => $email, 'email' => $email,
'name' => $name, 'name' => $name,
'exception' => $e->getMessage(), 'exception' => $e->getMessage(),
'trace' => $e->getTraceAsString() 'trace' => $e->getTraceAsString()
]); ]);
return false; return false;
} }
} }
function saml_settings() { function saml_settings() {
return array( return array(
// If 'strict' is True, then the PHP Toolkit will reject unsigned // If 'strict' is True, then the PHP Toolkit will reject unsigned
// or unencrypted messages if it expects them to be signed or encrypted. // or unencrypted messages if it expects them to be signed or encrypted.
// Also it will reject the messages if the SAML standard is not strictly // Also it will reject the messages if the SAML standard is not strictly
// followed: Destination, NameId, Conditions ... are validated too. // followed: Destination, NameId, Conditions ... are validated too.
// Should never be set to anything else in production! // Should never be set to anything else in production!
'strict' => true, 'strict' => true,
// Enable debug mode (to print errors). // Enable debug mode (to print errors).
'debug' => false, 'debug' => false,
// Set a BaseURL to be used instead of try to guess // Set a BaseURL to be used instead of try to guess
// the BaseURL of the view that process the SAML Message. // the BaseURL of the view that process the SAML Message.
// Ex http://sp.example.com/ // Ex http://sp.example.com/
// http://example.com/sp/ // http://example.com/sp/
'baseurl' => DI::baseUrl() . "/saml", 'baseurl' => DI::baseUrl() . "/saml",
// Service Provider Data that we are deploying. // Service Provider Data that we are deploying.
'sp' => array( 'sp' => array(
// Identifier of the SP entity (must be a URI) // Identifier of the SP entity (must be a URI)
'entityId' => DI::config()->get('saml','client_id'), 'entityId' => DI::config()->get('saml','client_id'),
// Specifies info about where and how the <AuthnResponse> message MUST be // Specifies info about where and how the <AuthnResponse> message MUST be
// returned to the requester, in this case our SP. // returned to the requester, in this case our SP.
'assertionConsumerService' => array( 'assertionConsumerService' => array(
// URL Location where the <Response> from the IdP will be returned // URL Location where the <Response> from the IdP will be returned
'url' => DI::baseUrl() . "/saml/sso", 'url' => DI::baseUrl() . "/saml/sso",
// SAML protocol binding to be used when returning the <Response> // SAML protocol binding to be used when returning the <Response>
// message. OneLogin Toolkit supports this endpoint for the // message. OneLogin Toolkit supports this endpoint for the
// HTTP-POST binding only. // HTTP-POST binding only.
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
), ),
// If you need to specify requested attributes, set a // If you need to specify requested attributes, set a
// attributeConsumingService. nameFormat, attributeValue and // attributeConsumingService. nameFormat, attributeValue and
// friendlyName can be omitted // friendlyName can be omitted
"attributeConsumingService"=> array( "attributeConsumingService"=> array(
"serviceName" => "Friendica SAML SSO and SLO Addon", "serviceName" => "Friendica SAML SSO and SLO Addon",
"serviceDescription" => "SLO and SSO support for Friendica", "serviceDescription" => "SLO and SSO support for Friendica",
"requestedAttributes" => array( "requestedAttributes" => array(
array( array(
"uid" => "", "uid" => "",
"isRequired" => false, "isRequired" => false,
) )
) )
), ),
// Specifies info about where and how the <Logout Response> message MUST be // Specifies info about where and how the <Logout Response> message MUST be
// returned to the requester, in this case our SP. // returned to the requester, in this case our SP.
'singleLogoutService' => array( 'singleLogoutService' => array(
// URL Location where the <Response> from the IdP will be returned // URL Location where the <Response> from the IdP will be returned
'url' => DI::baseUrl() . "/saml/slo", 'url' => DI::baseUrl() . "/saml/slo",
// SAML protocol binding to be used when returning the <Response> // SAML protocol binding to be used when returning the <Response>
// message. OneLogin Toolkit supports the HTTP-Redirect binding // message. OneLogin Toolkit supports the HTTP-Redirect binding
// only for this endpoint. // only for this endpoint.
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
), ),
// Specifies the constraints on the name identifier to be used to // Specifies the constraints on the name identifier to be used to
// represent the requested subject. // represent the requested subject.
// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported. // Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported.
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified', 'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
// Usually x509cert and privateKey of the SP are provided by files placed at // Usually x509cert and privateKey of the SP are provided by files placed at
// the certs folder. But we can also provide them with the following parameters // the certs folder. But we can also provide them with the following parameters
'x509cert' => DI::config()->get('saml','sp_cert'), 'x509cert' => DI::config()->get('saml','sp_cert'),
'privateKey' => DI::config()->get('saml','sp_key'), 'privateKey' => DI::config()->get('saml','sp_key'),
), ),
// Identity Provider Data that we want connected with our SP. // Identity Provider Data that we want connected with our SP.
'idp' => array( 'idp' => array(
// Identifier of the IdP entity (must be a URI) // Identifier of the IdP entity (must be a URI)
'entityId' => DI::config()->get('saml','idp_id'), 'entityId' => DI::config()->get('saml','idp_id'),
// SSO endpoint info of the IdP. (Authentication Request protocol) // SSO endpoint info of the IdP. (Authentication Request protocol)
'singleSignOnService' => array( 'singleSignOnService' => array(
// URL Target of the IdP where the Authentication Request Message // URL Target of the IdP where the Authentication Request Message
// will be sent. // will be sent.
'url' => DI::config()->get('saml','sso_url'), 'url' => DI::config()->get('saml','sso_url'),
// SAML protocol binding to be used when returning the <Response> // SAML protocol binding to be used when returning the <Response>
// message. OneLogin Toolkit supports the HTTP-Redirect binding // message. OneLogin Toolkit supports the HTTP-Redirect binding
// only for this endpoint. // only for this endpoint.
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
), ),
// SLO endpoint info of the IdP. // SLO endpoint info of the IdP.
'singleLogoutService' => array( 'singleLogoutService' => array(
// URL Location of the IdP where SLO Request will be sent. // URL Location of the IdP where SLO Request will be sent.
'url' => DI::config()->get('saml','slo_request_url'), 'url' => DI::config()->get('saml','slo_request_url'),
// URL location of the IdP where SLO Response will be sent (ResponseLocation) // URL location of the IdP where SLO Response will be sent (ResponseLocation)
// if not set, url for the SLO Request will be used // if not set, url for the SLO Request will be used
'responseUrl' => DI::config()->get('saml','slo_response_url'), 'responseUrl' => DI::config()->get('saml','slo_response_url'),
// SAML protocol binding to be used when returning the <Response> // SAML protocol binding to be used when returning the <Response>
// message. OneLogin Toolkit supports the HTTP-Redirect binding // message. OneLogin Toolkit supports the HTTP-Redirect binding
// only for this endpoint. // only for this endpoint.
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
), ),
// Public x509 certificate of the IdP // Public x509 certificate of the IdP
'x509cert' => DI::config()->get('saml','idp_cert'), 'x509cert' => DI::config()->get('saml','idp_cert'),
), ),
'security' => array ( 'security' => array (
'wantXMLValidation' => false, 'wantXMLValidation' => false,
// Indicates whether the <samlp:AuthnRequest> messages sent by this SP // Indicates whether the <samlp:AuthnRequest> messages sent by this SP
// will be signed. [Metadata of the SP will offer this info] // will be signed. [Metadata of the SP will offer this info]
'authnRequestsSigned' => true, 'authnRequestsSigned' => true,
// Indicates whether the <samlp:logoutRequest> messages sent by this SP // Indicates whether the <samlp:logoutRequest> messages sent by this SP
// will be signed. // will be signed.
'logoutRequestSigned' => true, 'logoutRequestSigned' => true,
// Indicates whether the <samlp:logoutResponse> messages sent by this SP // Indicates whether the <samlp:logoutResponse> messages sent by this SP
// will be signed. // will be signed.
'logoutResponseSigned' => true, 'logoutResponseSigned' => true,
/* Sign the Metadata */ /* Sign the Metadata */
'signMetadata' => true, 'signMetadata' => true,
) )
); );
} }
?> ?>