diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index 3c1c25bd..824c932d 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,1002 +1,1002 @@ * * Copyright (C) 2014-2022, Apheleia IT AG * * 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 . */ #[AllowDynamicProperties] class kolab_storage_dav_folder extends kolab_storage_folder { /** @var kolab_dav_client DAV Client */ public $dav; /** @var string Folder URL */ public $href; /** @var array Folder DAV attributes */ public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type = '') { $this->attributes = $attributes; $this->href = $this->attributes['href']; $this->id = kolab_storage_dav::folder_id($dav->url, $this->href); $this->dav = $dav; $this->valid = true; [$this->type, $suffix] = strpos($type, '.') ? explode('.', $type) : [$type, '']; $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; // Init cache if ($type) { $this->cache = kolab_storage_dav_cache::factory($this); } } /** * Get the folder ACL * * @return array Folder ACL list */ public function get_acl() { if (!isset($this->attributes['acl'])) { $this->get_folder_info(); } $acl = []; foreach ($this->attributes['acl'] ?? [] as $principal => $privileges) { // Convert a principal href into a user identifier if (strpos($principal, '/') !== false) { $tokens = explode('/', trim($principal, '/')); $principal = end($tokens); } $acl[$principal] = $privileges; } // Workaround for Cyrus issue https://github.com/cyrusimap/cyrus-imapd/issues/4813 // It converts single "authenticated" ACE into two "all" and "unauthenticated" // We'll convert it back into one, as we want to keep UI simplicity if (!empty($acl['all']['grant']) && !empty($acl['unauthenticated']['deny']) && $acl['all']['grant'] == $acl['unauthenticated']['deny'] ) { $acl['authenticated'] = $acl['all']; unset($acl['all']); unset($acl['unauthenticated']); } return $acl; } /** * Returns the owner of the folder. * * @param bool $fully_qualified Return a fully qualified owner name (i.e. including domain for shared folders) * * @return string The owner of this folder. */ public function get_owner($fully_qualified = false) { if (isset($this->owner)) { return $this->owner; } // TODO: Support global shared folders? if (isset($this->attributes['owner'])) { // Assume username/email is the last part of a principal href $path = explode('/', trim($this->attributes['owner'], '/')); - $this->owner = end($path); + $this->owner = rawurldecode(end($path)); } else { $rcube = rcube::get_instance(); $this->owner = $rcube->get_user_name(); } return $this->owner; } /** * Get a folder Etag identifier */ public function get_ctag() { // Refresh requested, get the current CTag from the DAV server if ($this->attributes['ctag'] === false) { if ($fresh = $this->dav->folderInfo($this->href)) { $this->attributes['ctag'] = $fresh['ctag']; } } return $this->attributes['ctag']; } /** * Getter for the name of the namespace to which the folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { // TODO: Support for global shared folders? $owner = $this->get_owner(); $user = rcube::get_instance()->get_user_name(); if ($owner != $user) { return 'other'; } return 'personal'; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { $name = $this->attributes['name']; if ($this->get_namespace() == 'other') { $name = $this->get_owner() . ': ' . $name; } return $name; } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { return $this->attributes['name']; } /** * Get (more) folder properties * * @return array Folder properties */ public function get_folder_info() { if ($info = $this->dav->folderInfo($this->href)) { $this->attributes = array_merge($info, $this->attributes); } return $this->attributes; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { // TODO return ''; } /** * Compose a unique resource URI for this folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // compose fully qualified resource uri for this instance $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; $host_path = parse_url($host, PHP_URL_PATH); if ($host_path && strpos($path, $host_path) === 0) { $path = substr($path, strlen($host_path)); } $this->resource_uri = unslashify($host) . $path; return $this->resource_uri; } /** * Getter for the Cyrus mailbox identifier corresponding to this folder * (e.g. user/john.doe/Calendar/Personal@example.org) * * @return string Mailbox ID */ public function get_mailbox_id() { // TODO: This is used with Bonnie related features return ''; } /** * Get the color value stored in metadata * * @param string $default Default color value to return if not set * * @return mixed Color value from the folder metadata or $default if not set */ public function get_color($default = null) { return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; } /** * Get current user permissions to this folder * * @return string DAV privileges (comma-separated) */ public function get_myrights() { if (!isset($this->attributes['myrights'])) { $this->get_folder_info(); } $rights = $this->attributes['myrights'] ?? []; $types = $this->attributes['resource_type'] ?? []; $acl = 'lr'; // Convert DAV privileges to IMAP-like ACL format foreach ($rights as $right) { switch ($right) { case 'all': $acl .= 'lrswikxteav'; break; case 'admin': $acl .= 'a'; break; case 'read': case 'read-free-busy': case 'read-current-user-privilege-set': $acl .= 'lr'; break; case 'read-write': $acl .= 'lrwni'; break; case 'write': //case 'write-properties': //case 'write-content': $acl .= 'wni'; break; case 'bind': $acl .= 'pk'; break; case 'unbind': $acl .= 'xte'; break; case 'share': $acl .= '1'; break; } } // Shared collection can be deleted even if myrights says otherwise if (in_array('shared', $types)) { $acl .= 'x'; } if (!empty($this->attributes['share-access'])) { if ($this->attributes['share-access'] === kolab_dav_client::SHARING_OWNER) { $acl .= 'a'; } elseif ($this->attributes['share-access'] === kolab_dav_client::SHARING_READ || $this->attributes['share-access'] === kolab_dav_client::SHARING_READ_WRITE ) { $acl .= 'x'; } } // Note: We return a string for compat. with the parent class return implode('', array_unique(str_split($acl))); } /** * Get the invitations' sharees * * @return array Sharees list */ public function get_sharees() { if (!isset($this->attributes['invite'])) { $this->get_folder_info(); } $sharees = []; foreach ($this->attributes['invite'] ?? [] as $principal => $sharee) { // Convert a principal href into a user identifier if (stripos($principal, 'mailto:') === 0) { $principal = substr($principal, 7); } elseif (strpos($principal, '/') !== false) { $tokens = explode('/', trim($principal, '/')); $principal = end($tokens); } $sharees[$principal] = $sharee; } return $sharees; } /** * Helper method to extract folder UID * * @return string|null Folder's UID */ public function get_uid() { return $this->id; } /** * Check activation status of this folder * * @return bool True if enabled, false if not */ public function is_active() { return true; // Unused } /** * Change activation status of this folder * * @param bool $active The desired subscription status: true = active, false = not active * * @return bool True on success, false on error */ public function activate($active) { return true; // Unused } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { return true; // TODO } /** * Change subscription status of this folder * * @param bool $subscribed The desired subscription status: true = subscribed, false = not subscribed * * @return bool True on success, false on error */ public function subscribe($subscribed) { return true; // TODO } /** * Delete the specified object from this folder. * * @param array|string $object The Kolab object to delete or object UID * @param bool $expunge Should the folder be expunged? * * @return bool True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $uid = is_array($object) ? $object['uid'] : $object; $success = $this->dav->delete($this->object_location($uid)); if ($success) { $this->cache->set($uid, false); } return $success; } /** * Delete all objects in a folder. * * Note: This method is used by kolab_addressbook plugin only * * @return bool True if successful, false on error */ public function delete_all() { if (!$this->valid) { return false; } // TODO: Maybe just deleting and re-creating a folder would be // better, but probably might not always work (ACL) $this->cache->synchronize(); foreach (array_keys($this->cache->folder_index()) as $uid) { $this->dav->delete($this->object_location($uid)); } $this->cache->purge(); return true; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * * @param string $uid Object UID * @param kolab_storage_dav_folder $target_folder Target folder to move object into * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } $source = $this->object_location($uid); $target = $target_folder->object_location($uid); $success = $this->dav->move($source, $target) !== false; if ($success) { $this->cache->set($uid, false); $target_folder->reset(); $this->reset(); } return $success; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or object UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) { $type = $this->type; } $result = false; if (empty($uid)) { if (empty($object['created'])) { $object['created'] = new DateTime('now'); } } else { $object['changed'] = new DateTime('now'); } // Make sure UID exists if (empty($object['uid'])) { if ($uid) { $object['uid'] = $uid; } else { $username = rcube::get_instance()->user->get_username(); $object['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($username), 0, 16)); } } // generate and save object message if ($content = $this->to_dav($object)) { $method = $uid ? 'update' : 'create'; $dav_type = $this->get_dav_type(); $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); // Note: $result can be NULL if the request was successful, but ETag wasn't returned if ($result !== false) { // insert/update object in the cache $object['etag'] = $result; $object['_raw'] = $content; $this->cache->save($object, $uid); $result = true; unset($object['_raw']); } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string $uid The object UID to fetch * @param string $type The object type expected (use wildcard '*' to accept all types) * @param string $folder Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}", ], true); return false; } return $this->from_dav($objects[0]); } /** * Fetch multiple objects from the DAV server and convert to internal format * * @param array $uids The object UIDs to fetch * * @return mixed Hash array representing the Kolab objects */ public function read_objects($uids) { if (!$this->valid) { return false; } if (empty($uids)) { return []; } $hrefs = []; foreach ($uids as $uid) { $hrefs[] = $this->object_location($uid); } $objects = $this->dav->getData($this->href, $this->get_dav_type(), $hrefs); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch objects from {$this->href}", ], true); return false; } $objects = array_map([$this, 'from_dav'], $objects); foreach ($uids as $idx => $uid) { foreach ($objects as $oidx => $object) { if ($object && $object['uid'] == $uid) { $uids[$idx] = $object; unset($objects[$oidx]); continue 2; } } $uids[$idx] = false; } return $uids; } /** * Reset the folder status */ public function reset() { $this->cache->reset(); $this->attributes['ctag'] = false; } /** * Convert DAV object into PHP array * * @param array $object Object data in kolab_dav_client::fetchData() format * * @return array|false Object properties, False on error */ public function from_dav($object) { if (empty($object) || empty($object['data'])) { return false; } if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); $objects = $ical->import($object['data']); if (!count($objects) || empty($objects[0]['uid'])) { return false; } $result = $objects[0]; $result['_attachments'] = $result['attachments'] ?? []; unset($result['attachments']); } elseif ($this->type == 'contact') { if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { return false; } // vCard properties not supported by rcube_vcard $map = [ 'uid' => 'UID', 'kind' => 'KIND', 'member' => 'MEMBER', 'x-kind' => 'X-ADDRESSBOOKSERVER-KIND', 'x-member' => 'X-ADDRESSBOOKSERVER-MEMBER', ]; // TODO: We should probably use Sabre/Vobject to parse the vCard $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false, $map); if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { $result = $vcard->get_assoc(); // Contact groups if (!empty($result['x-kind']) && implode('', $result['x-kind']) == 'group') { $result['_type'] = 'group'; $members = $result['x-member'] ?? []; unset($result['x-kind'], $result['x-member']); } elseif (!empty($result['kind']) && implode('', $result['kind']) == 'group') { $result['_type'] = 'group'; $members = $result['member'] ?? []; unset($result['kind'], $result['member']); } if (isset($members)) { $result['member'] = []; foreach ($members as $member) { if (strpos($member, 'urn:uuid:') === 0) { $result['member'][] = ['uid' => substr($member, 9)]; } elseif (strpos($member, 'mailto:') === 0) { $member = reset(rcube_mime::decode_address_list(urldecode(substr($member, 7)))); if (!empty($member['mailto'])) { $result['member'][] = ['email' => $member['mailto'], 'name' => $member['name']]; } } } } if (!empty($result['uid'])) { $result['uid'] = preg_replace('/^urn:uuid:/', '', implode('', $result['uid'])); } } else { return false; } } $result['etag'] = $object['etag']; $result['href'] = !empty($object['href']) ? $object['href'] : null; $result['uid'] = !empty($object['uid']) ? $object['uid'] : $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } $object['_type'] = $this->type; // pre-process attachments if (isset($object['_attachments']) && is_array($object['_attachments'])) { foreach ($object['_attachments'] as $key => $attachment) { if ($attachment === false) { // Deleted attachment unset($object['_attachments'][$key]); continue; } // make sure size is set if (!isset($attachment['size'])) { if (!empty($attachment['data'])) { if (is_resource($attachment['data'])) { // this need to be a seekable resource, otherwise // fstat() fails and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['data']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['data']); } } elseif (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } } } $object['attachments'] = $object['_attachments'] ?? []; unset($object['_attachments']); $result = $ical->export([$object], null, false, [$this, 'get_attachment']); } elseif ($this->type == 'contact') { // copy values into vcard object // TODO: We should probably use Sabre/Vobject to create the vCard // vCard properties not supported by rcube_vcard $map = ['uid' => 'UID', 'kind' => 'KIND']; $vcard = new rcube_vcard('', RCUBE_CHARSET, false, $map); if ((!empty($object['_type']) && $object['_type'] == 'group') || (!empty($object['type']) && $object['type'] == 'group') ) { $object['kind'] = 'group'; } foreach ($object as $key => $values) { [$field, $section] = rcube_utils::explode(':', $key); // avoid casting DateTime objects to array if (is_object($values) && $values instanceof DateTimeInterface) { $values = [$values]; } foreach ((array) $values as $value) { if (isset($value)) { $vcard->set($field, $value, $section); } } } $result = $vcard->export(false); if (!empty($object['kind']) && $object['kind'] == 'group') { $members = ''; foreach ((array) $object['member'] as $member) { $value = null; if (!empty($member['uid'])) { $value = 'urn:uuid:' . $member['uid']; } elseif (!empty($member['email']) && !empty($member['name'])) { $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); } elseif (!empty($member['email'])) { $value = 'mailto:' . $member['email']; } if ($value) { $members .= "MEMBER:{$value}\r\n"; } } if ($members) { $result = preg_replace('/\r\nEND:VCARD/', "\r\n{$members}END:VCARD", $result); } /** Version 4.0 of the vCard format requires Cyrus >= 3.6.0, we'll use Version 3.0 for now $result = preg_replace('/\r\nVERSION:3\.0\r\n/', "\r\nVERSION:4.0\r\n", $result); $result = preg_replace('/\r\nN:[^\r]+/', '', $result); $result = preg_replace('/\r\nUID:([^\r]+)/', "\r\nUID:urn:uuid:\\1", $result); */ $result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result); $result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result); } } if ($result) { // The content must be UTF-8, otherwise if we try to fetch the object // from server XML parsing would fail. $result = rcube_charset::clean($result); } return $result; } public function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } /** * Get a folder DAV content type */ public function get_dav_type() { return kolab_storage_dav::get_dav_type($this->type); } /** * Get body of an attachment */ public function get_attachment($id, $event, $unused1 = null, $unused2 = false, $unused3 = null, $unused4 = false) { /** @var array $event Overwrite type from the parent class */ // Note: 'attachments' is defined when saving the data into the DAV server // '_attachments' is defined after fetching the object from the DAV server if (is_int($id) && isset($event['attachments'][$id])) { $attachment = $event['attachments'][$id]; } elseif (is_int($id) && isset($event['_attachments'][$id])) { $attachment = $event['_attachments'][$id]; } elseif (is_string($id) && !empty($event['attachments'])) { foreach ($event['attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } elseif (is_string($id) && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } if (empty($attachment)) { return false; } if (!empty($attachment['path'])) { return file_get_contents($attachment['path']); } return $attachment['data'] ?? null; } /** * Get a DAV file extension for specified Kolab type */ public function get_dav_ext() { $types = [ 'event' => 'ics', 'task' => 'ics', 'contact' => 'vcf', ]; return $types[$this->type]; } /** * Set ACL for the folder. * * @param array $acl ACL list * * @return bool True if successful, false on error */ public function set_acl($acl) { if (!$this->valid) { return false; } // In Kolab the users (principals) are under /principals/user/ // TODO: This might need to be configurable or discovered somehow $path = '/principals/user/'; if ($host_path = parse_url($this->dav->url, PHP_URL_PATH)) { $path = '/' . trim($host_path, '/') . $path; } $specials = ['all', 'authenticated', 'self']; $request = []; foreach ($acl as $principal => $privileges) { // Convert a user identifier into a principal href if (!in_array($principal, $specials)) { $principal = $path . $principal; } $request[$principal] = $privileges; } return $this->dav->setACL($this->href, $request); } /** * Set folder sharing invites. * * @param array $sharees Sharees list * * @return bool True if successful, false on error */ public function set_sharees($sharees) { if (!$this->valid) { return false; } // In Kolab the users (principals) are under /principals/user/ // TODO: This might need to be configurable or discovered somehow $path = '/principals/user/'; if ($host_path = parse_url($this->dav->url, PHP_URL_PATH)) { $path = '/' . trim($host_path, '/') . $path; } $request = []; foreach ($sharees as $principal => $sharee) { // Convert a user identifier into a principal href or mailto: href if (strpos($principal, '@')) { $principal = 'mailto:' . $principal; } else { $principal = $path . $principal; } $request[$principal] = $sharee; } return $this->dav->shareResource($this->href, $request); } /** * Return folder name as string representation of this object * * @return string Folder display name */ public function __toString() { return $this->attributes['name']; } }