Page MenuHomePhorge

No OneTemporary

Size
28 KB
Referenced Files
None
Subscribers
None
diff --git a/lib/Kolab/CardDAV/LDAPCard.php b/lib/Kolab/CardDAV/LDAPCard.php
new file mode 100644
index 0000000..c563e37
--- /dev/null
+++ b/lib/Kolab/CardDAV/LDAPCard.php
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * Class that represents a single vCard node from an LDAP directory
+ * with limited permissions (read-only).
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ *
+ * Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace Kolab\CardDAV;
+
+use Sabr\DAV;
+
+/**
+ * Represents a single vCard from an LDAP directory
+ */
+class LDAPCard extends \Sabre\CardDAV\Card
+{
+ /**
+ * Updates the VCard-formatted object
+ *
+ * @param string $cardData
+ * @return string|null
+ */
+ public function put($cardData)
+ {
+ throw new DAV\Exception\MethodNotAllowed('Modifying directory entries is not allowed');
+ }
+
+ /**
+ * Deletes the card
+ *
+ * @return void
+ */
+ public function delete()
+ {
+ throw new DAV\Exception\MethodNotAllowed('Deleting directory entries is not allowed');
+ }
+
+ /**
+ * Returns a list of ACE's for directory entries.
+ *
+ * @return array
+ */
+ public function getACL() {
+
+ return array(
+ array(
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->addressBookInfo['principaluri'],
+ 'protected' => true,
+ ),
+ );
+
+ }
+}
+
diff --git a/lib/Kolab/CardDAV/LDAPDirectory.php b/lib/Kolab/CardDAV/LDAPDirectory.php
index 622ce29..70fae38 100644
--- a/lib/Kolab/CardDAV/LDAPDirectory.php
+++ b/lib/Kolab/CardDAV/LDAPDirectory.php
@@ -1,484 +1,487 @@
<?php
/**
* CardDAV Directory class providing read-only access
* to an LDAP-based global address book.
*
* This implements the CardDAV Directory Gateway Extension suggested by Apple Inc.
* http://tools.ietf.org/html/draft-daboo-carddav-directory-gateway-02
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab\CardDAV;
use \rcube;
use \rcube_ldap;
use \rcube_ldap_generic;
use Sabre\DAV;
use Sabre\DAVACL;
-use Sabre\CardDAV\Card;
use Sabre\CardDAV\Property;
/**
* CardDAV Directory Gateway implementation
*/
class LDAPDirectory extends DAV\Collection implements \Sabre\CardDAV\IDirectory, DAV\IProperties, DAVACL\IACL
{
const DIRECTORY_NAME = 'ldap-directory';
private $config;
private $ldap;
private $carddavBackend;
private $principalUri;
private $addressBookInfo = array();
private $uid2id = array();
private $query;
private $filter;
/**
* Default constructor
*/
function __construct($config, $principalUri, $carddavBackend = null)
{
$this->config = $config;
$this->principalUri = $principalUri;
$this->addressBookInfo = array(
'id' => self::DIRECTORY_NAME,
'uri' => self::DIRECTORY_NAME,
'{DAV:}displayname' => $config['name'] ?: "LDAP Directory",
'{urn:ietf:params:xml:ns:caldav}supported-address-data' => new Property\SupportedAddressData(),
'principaluri' => $principalUri,
);
// used for vcard serialization
$this->carddavBackend = $carddavBackend ?: new ContactsBackend();
}
private function connect()
{
if (!isset($this->ldap)) {
$this->ldap = new rcube_ldap($this->config, $this->config['debug']);
$this->ldap->set_pagesize($this->config['sizelimit'] ?: 10000);
}
return $this->ldap->ready ? $this->ldap : null;
}
/**
* Set parsed addressbook-query object for filtering
*/
function setAddressbookQuery($query)
{
$this->query = $query;
$this->filter = $this->addressbook_query2ldap_filter($query);
}
/**
* Returns the name of the node.
*
* This is used to generate the url.
*
* @return string
*/
function getName()
{
return self::DIRECTORY_NAME;
}
/**
* Returns a specific child node, referenced by its name
*
* This method must throw Sabre\DAV\Exception\NotFound if the node does not
* exist.
*
* @param string $name
* @return DAV\INode
*/
function getChild($cardUri)
{
console(__METHOD__, $cardUri);
$uid = basename($cardUri, '.vcf');
$record = null;
// TODO: get from cache
if ($ldap = $this->connect()) {
// used cached uid mapping
if ($ID = $this->uid2id[$uid]) {
- $record = $ldap->get_record($ID, true);
+ $contact = $ldap->get_record($ID, true);
}
else { // query for uid
$result = $ldap->search('uid', $uid, 1, true, true);
if ($result->count) {
- $record = $result[0];
+ $contact = $result[0];
}
}
- if ($record) {
- $this->_normalize_contact($record);
+ if ($contact) {
+ $this->_normalize_contact($contact);
$obj = array(
'id' => $contact['uid'],
'uri' => $contact['uid'] . '.vcf',
'lastmodified' => $contact['_timestamp'],
'carddata' => $this->carddavBackend->to_vcard($contact),
'etag' => self::_get_etag($contact),
);
- return new Card($this->carddavBackend, $this->addressBookInfo, $obj);
+ return new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj);
}
}
throw new DAV\Exception\NotFound('Card not found');
}
/**
* Returns an array with all the child nodes
*
* @return DAV\INode[]
*/
function getChildren()
{
console(__METHOD__, $this->query, $this->filter);
$children = array();
// query LDAP if we have a search query or listing is allowed
if (($this->query || !$this->config['searchonly']) && ($ldap = $this->connect())) {
// set pagesize from query limit attribute
if ($this->query && $this->query->limit) {
$this->ldap->set_pagesize(intval($this->query->limit));
}
// set the prepared LDAP filter derived from the addressbook-query
if ($this->query && !empty($this->filter)) {
$ldap->set_search_set($this->filter);
}
else {
$ldap->set_search_set(null);
}
$results = $ldap->list_records(null);
// convert restuls into vcard blocks
foreach ($results as $contact) {
$this->_normalize_contact($contact);
$obj = array(
'id' => $contact['uid'],
'uri' => $contact['uid'] . '.vcf',
'lastmodified' => $contact['_timestamp'],
'carddata' => $this->carddavBackend->to_vcard($contact),
'etag' => self::_get_etag($contact),
);
// TODO: cache result
$this->uid2id[$contact['uid']] = $contact['ID'];
- $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj);
+ $children[] = new LDAPCard($this->carddavBackend, $this->addressBookInfo, $obj);
}
}
return $children;
}
/**
* Returns a list of properties for this node.
*
* The properties list is a list of propertynames the client requested,
* encoded in clark-notation {xmlnamespace}tagname
*
* If the array is empty, it means 'all properties' were requested.
*
* @param array $properties
* @return array
*/
public function getProperties($properties)
{
console(__METHOD__, $properties);
$response = array();
foreach ($properties as $propertyName) {
if (isset($this->addressBookInfo[$propertyName])) {
$response[$propertyName] = $this->addressBookInfo[$propertyName];
}
else if ($propertyName == '{DAV:}getlastmodified') {
$response[$propertyName] = new DAV\Property\GetLastModified($this->getLastModified());
}
}
return $response;
}
/**
* Returns the last modification time, as a unix timestamp
*
* @return int
*/
function getLastModified()
{
console(__METHOD__);
return time();
}
/**
* Deletes the entire addressbook.
*
* @return void
*/
public function delete()
{
throw new DAV\Exception\MethodNotAllowed('Deleting directories is not allowed');
}
/**
* Renames the addressbook
*
* @param string $newName
* @return void
*/
public function setName($newName)
{
throw new DAV\Exception\MethodNotAllowed('Renaming directories not allowed');
}
/**
* Returns the owner principal
*
* This must be a url to a principal, or null if there's no owner
*
* @return string|null
*/
public function getOwner()
{
return $this->principalUri;
}
/**
* Returns a group principal
*
* This must be a url to a principal, or null if there's no owner
*
* @return string|null
*/
function getGroup()
{
return null;
}
/**
* Returns a list of ACE's for this node.
*
* Each ACE has the following properties:
* * 'privilege', a string such as {DAV:}read or {DAV:}write
* * 'principal', a url to the principal who owns the node
* * 'protected' (optional), indicating that this ACE is not allowed to be updated.
*
* @return array
*/
public function getACL()
{
$acl = array(
array(
'privilege' => '{DAV:}read',
'principal' => $this->principalUri,
'protected' => true,
),
);
}
/**
* Updates the ACL
*
* @param array $acl
* @return void
*/
function setACL(array $acl)
{
throw new DAV\Exception\MethodNotAllowed('Changing ACL for directories is not allowed');
}
/**
* Returns the list of supported privileges for this node.
*
* If null is returned from this method, the default privilege set is used,
* which is fine for most common usecases.
*
* @return array|null
*/
function getSupportedPrivilegeSet()
{
return null;
}
/**
* Updates properties on this node,
*
* @param array $mutations
* @return bool|array
*/
function updateProperties($mutations)
{
console(__METHOD__, $mutations);
return false;
}
/**
* Post-process the given contact record from rcube_ldap
*/
private function _normalize_contact(&$contact)
{
if (is_numeric($contact['changed'])) {
$contact['_timestamp'] = $contact['changed'];
$contact['changed'] = new \DateTime('@' . $contact['changed']);
}
else if (!empty($contact['changed'])) {
try {
$contact['changed'] = new \DateTime($contact['changed']);
$contact['_timestamp'] = $contact['changed']->format('U');
}
catch (Exception $e) {
$contact['changed'] = null;
}
}
// map col:subtype fields to a list that the vcard serialization function understands
foreach (array('email' => 'address', 'phone' => 'number', 'website' => 'url') as $col => $prop) {
foreach (rcube_ldap::get_col_values($col, $contact) as $type => $values) {
foreach ($values as $value) {
$contact[$col][] = array($prop => $value, 'type' => $type);
}
}
}
}
/**
* Translate the given AddressBookQueryParser object into an LDAP filter
*/
private function addressbook_query2ldap_filter($query)
{
$criterias = array();
foreach ($query->filters as $filter) {
$ldap_attrs = $this->map_property2ldap($filter['name']);
$ldap_filter = ''; $count = 0;
// unknown attribute, skip
if (empty($ldap_attrs)) {
continue;
}
foreach ((array)$filter['text-matches'] as $matcher) {
// case-insensitive matching
if (in_array($matcher['collation'], array('i;unicode-casemap', 'i;ascii-casemap'))) {
$matcher['value'] = mb_strtolower($matcher['value']);
}
$value = rcube_ldap_generic::quote_string($matcher['value']);
$ldap_match = '';
// this assumes fuzzy search capabilities of the LDAP backend
switch ($matcher['match-type']) {
case 'contains':
$wp = $ws = '*';
break;
case 'starts-with':
$ws = '*';
break;
case 'ends-with':
$wp = '*';
break;
default:
$wp = $ws = '';
}
// OR query for all attributes involved
if (count($ldap_attrs) > 1) {
$ldap_match .= '(|';
}
foreach ($ldap_attrs as $attr) {
$ldap_match .= "($attr=$wp$value$ws)";
}
if (count($ldap_attrs) > 1) {
$ldap_match .= ')';
}
// negate the filter
if ($matcher['negate-condition']) {
$ldap_match = '(!' . $ldap_match . ')';
}
$ldap_filter .= $ldap_match;
$count++;
}
if ($count > 1) {
$criterias[] = '(' . ($filter['test'] == 'allof' ? '&' : '|') . $ldap_filter . ')';
}
else if (!empty($ldap_filter)) {
$criterias[] = $ldap_filter;
}
}
return empty($criterias) ? '' : sprintf('(%s%s)', $query->test == 'allof' ? '&' : '|', join('', $criterias));
}
/**
* Map a vcard property to an LDAP attribute
*/
private function map_property2ldap($propname)
{
$attribs = array();
- $ldap = $this->connect();
+
+ // LDAP backend not available, abort
+ if (!($ldap = $this->connect())) {
+ return $attribs;
+ }
$vcard_fieldmap = array(
'FN' => array('name'),
'N' => array('surname','firstname','middlename'),
'ADR' => array('street','locality','region','code','country'),
'TITLE' => array('jobtitle'),
'ORG' => array('organization','department'),
'TEL' => array('phone'),
'URL' => array('website'),
'ROLE' => array('profession'),
'BDAY' => array('birthday'),
'IMPP' => array('im'),
);
$fields = $vcard_fieldmap[$propname] ?: array(strtolower($propname));
foreach ($fields as $field) {
if ($ldap->coltypes[$field]) {
$attribs = array_merge($attribs, (array)$ldap->coltypes[$field]['attributes']);
}
}
return $attribs;
}
/**
* Generate an Etag string from the given contact data
*
* @param array Hash array with contact properties from libkolab
* @return string Etag string
*/
private static function _get_etag($contact)
{
return sprintf('"%s-%d"', substr(md5($contact['uid']), 0, 16), $contact['_timestamp']);
}
}
diff --git a/lib/Kolab/CardDAV/Plugin.php b/lib/Kolab/CardDAV/Plugin.php
index 1456e78..adf8151 100644
--- a/lib/Kolab/CardDAV/Plugin.php
+++ b/lib/Kolab/CardDAV/Plugin.php
@@ -1,203 +1,203 @@
<?php
/**
* Extended CardDAV plugin for the Kolab DAV server
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2013, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab\CardDAV;
use Sabre\DAV;
use Sabre\DAVACL;
use Sabre\CardDAV;
use Sabre\VObject;
/**
* Extended CardDAV plugin to tweak data validation
*/
class Plugin extends CardDAV\Plugin
{
// make already parsed vcard blocks available for later use
public static $parsed_vcard;
// allow the backend to force a redirect Location
public static $redirect_basename;
/**
* Initializes the plugin
*
* @param DAV\Server $server
* @return void
*/
public function initialize(DAV\Server $server)
{
parent::initialize($server);
$server->subscribeEvent('beforeMethod', array($this, 'beforeMethod'));
$server->subscribeEvent('afterCreateFile', array($this, 'afterWriteContent'));
$server->subscribeEvent('afterWriteContent', array($this, 'afterWriteContent'));
}
/**
* Adds all CardDAV-specific properties
*
* @param string $path
* @param DAV\INode $node
* @param array $requestedProperties
* @param array $returnedProperties
* @return void
*/
public function beforeGetProperties($path, DAV\INode $node, array &$requestedProperties, array &$returnedProperties)
{
// publish global ldap address book for this principal
- if ($node instanceof DAVACL\IPrincipal && empty($this->directories) && \rcube::get_instance()->config->get('global_ldap_directory')) {
+ if ($node instanceof DAVACL\IPrincipal && empty($this->directories) && \rcube::get_instance()->config->get('kolabdav_ldap_directory')) {
$this->directories[] = self::ADDRESSBOOK_ROOT . '/' . $node->getName() . '/' . LDAPDirectory::DIRECTORY_NAME;
}
parent::beforeGetProperties($path, $node, $requestedProperties, $returnedProperties);
}
/**
* Handler for beforeMethod events
*/
public function beforeMethod($method, $uri)
{
if ($method == 'PUT' && $this->server->httpRequest->getHeader('If-None-Match') == '*') {
// In-None-Match: * is only valid with PUT requests creating a new resource.
// SOGo Conenctor for Thunderbird also sends it with update requests which then fail
// in the Server::checkPreconditions().
// See https://issues.kolab.org/show_bug.cgi?id=2589 and http://www.sogo.nu/bugs/view.php?id=1624
// This is a work-around for the buggy SOGo connector and should be removed once fixed.
if (strpos($this->server->httpRequest->getHeader('User-Agent'), 'Thunderbird/') > 0) {
unset($_SERVER['HTTP_IF_NONE_MATCH']);
}
}
}
/**
* Inject some additional HTTP response headers
*/
public function afterWriteContent($uri, $node)
{
// send Location: header to corrected URI
if (self::$redirect_basename) {
$path = explode('/', $uri);
array_pop($path);
array_push($path, self::$redirect_basename);
$this->server->httpResponse->setHeader('Location', $this->server->getBaseUri() . join('/', array_map('urlencode', $path)));
self::$redirect_basename = null;
}
}
/**
* Checks if the submitted iCalendar data is in fact, valid.
*
* An exception is thrown if it's not.
*
* @param resource|string $data
* @return void
*/
protected function validateVCard(&$data)
{
// If it's a stream, we convert it to a string first.
if (is_resource($data)) {
$data = stream_get_contents($data);
}
// Converting the data to unicode, if needed.
$data = DAV\StringUtil::ensureUTF8($data);
try {
VObject\Property::$classMap['REV'] = 'Sabre\\VObject\\Property\\DateTime';
$vobj = VObject\Reader::read($data, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES);
if ($vobj->name == 'VCARD')
$this->parsed_vcard = $vobj;
}
catch (VObject\ParseException $e) {
throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid vcard data. Parse error: ' . $e->getMessage());
}
if ($vobj->name !== 'VCARD') {
throw new DAV\Exception\UnsupportedMediaType('This collection can only support vcard objects.');
}
if (!isset($vobj->UID)) {
throw new DAV\Exception\BadRequest('Every vcard must have a UID.');
}
}
/**
* This function handles the addressbook-query REPORT
*
* This report is used by the client to filter an addressbook based on a
* complex query.
*
* @param \DOMNode $dom
* @return void
*/
protected function addressbookQueryReport($dom)
{
$node = $this->server->tree->getNodeForPath(($uri = $this->server->getRequestUri()));
console(__METHOD__, $uri);
// fix some stupid mistakes in queries sent by the SOGo connector
$xpath = new \DOMXPath($dom);
$xpath->registerNameSpace('card', Plugin::NS_CARDDAV);
$filters = $xpath->query('/card:addressbook-query/card:filter');
if ($filters->length === 1) {
$filter = $filters->item(0);
$propFilters = $xpath->query('card:prop-filter', $filter);
for ($ii=0; $ii < $propFilters->length; $ii++) {
$propFilter = $propFilters->item($ii);
$name = $propFilter->getAttribute('name');
// attribute 'mail' => EMAIL
if ($name == 'mail') {
$propFilter->setAttribute('name', 'EMAIL');
}
$textMatches = $xpath->query('card:text-match', $propFilter);
for ($jj=0; $jj < $textMatches->length; $jj++) {
$textMatch = $textMatches->item($jj);
$collation = $textMatch->getAttribute('collation');
// 'i;unicasemap' is a non-standard collation
if ($collation == 'i;unicasemap') {
$textMatch->setAttribute('collation', 'i;unicode-casemap');
}
}
}
}
// query on LDAP node: pass along filter query
if ($node instanceof LDAPDirectory) {
$query = new CardDAV\AddressBookQueryParser($dom);
$query->parse();
// set query and ...
$node->setAddressbookQuery($query);
}
// ... proceed with default action
parent::addressbookQueryReport($dom);
}
}
\ No newline at end of file
diff --git a/lib/Kolab/CardDAV/UserAddressBooks.php b/lib/Kolab/CardDAV/UserAddressBooks.php
index db71bbe..4d9063a 100644
--- a/lib/Kolab/CardDAV/UserAddressBooks.php
+++ b/lib/Kolab/CardDAV/UserAddressBooks.php
@@ -1,94 +1,94 @@
<?php
/**
* SabreDAV UserAddressBooks derived class for the Kolab.
*
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2013, Kolab Systems AG <contact@kolabsys.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Kolab\CardDAV;
use \rcube;
use Sabre\DAV;
use Sabre\DAVACL;
/**
* UserAddressBooks class
*
* The UserAddressBooks collection contains a list of addressbooks associated with a user
*/
class UserAddressBooks extends \Sabre\CardDAV\UserAddressBooks implements DAV\IExtendedCollection, DAVACL\IACL
{
// pseudo-singleton instance
private $ldap_directory;
/**
* Returns a list of addressbooks
*
* @return array
*/
public function getChildren()
{
$addressbooks = $this->carddavBackend->getAddressbooksForUser($this->principalUri);
$objs = array();
foreach($addressbooks as $addressbook) {
$objs[] = new AddressBook($this->carddavBackend, $addressbook);
}
- if (rcube::get_instance()->config->get('global_ldap_directory')) {
+ if (rcube::get_instance()->config->get('kolabdav_ldap_directory')) {
$objs[] = $this->getLDAPDirectory();
}
return $objs;
}
/**
* Returns a single addressbook, by name
*
* @param string $name
* @return \AddressBook
*/
public function getChild($name)
{
if ($name == LDAPDirectory::DIRECTORY_NAME) {
return $this->getLDAPDirectory();
}
if ($addressbook = $this->carddavBackend->getAddressBookByName($name)) {
$addressbook['principaluri'] = $this->principalUri;
return new AddressBook($this->carddavBackend, $addressbook);
}
throw new DAV\Exception\NotFound('Addressbook with name \'' . $name . '\' could not be found');
}
/**
* Getter for the singleton instance of the LDAP directory
*/
private function getLDAPDirectory()
{
if (!$this->ldap_directory) {
$rcube = rcube::get_instance();
- $config = $rcube->config->get('global_ldap_directory');
+ $config = $rcube->config->get('kolabdav_ldap_directory');
$config['debug'] = $rcube->config->get('ldap_debug');
$this->ldap_directory = new LDAPDirectory($config, $this->principalUri, $this->carddavBackend);
}
return $this->ldap_directory;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Feb 3, 1:21 PM (19 h, 22 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427246
Default Alt Text
(28 KB)

Event Timeline