diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 254f22b448..6f2c5cb431 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -230,6 +230,31 @@ class addressbook_groupdav extends Api\CalDAV\Handler if (array_key_exists('tid', $filter) && !isset($filter['tid']) && !in_array('tid', $cols)) $cols[] = 'tid'; if (($contacts =& $this->bo->search(array(),$cols,$order,'','',False,'AND',$start,$filter))) { + // filter[tid] === null also returns no longer shared contacts, to remove them from devices, we need to mark them here as deleted + // to do so we need to read not deleted sharing info of potential candidates (not deleted and no regular access), as search does NOT + $id2key = []; + foreach($contacts as $key => &$contact) + { + if ($contact['tid'] !== Api\Contacts::DELETED_TYPE && + // check for (deleted) shared access + (!isset($filter['owner']) || !in_array($contact['owner'], (array)$filter['owner'])) && + !$this->bo->check_perms(Acl::READ, $contact, false, $this->user, 0)) + { + $id2key[$contact['id']] = $key; + } + } + if ($id2key) + { + foreach($this->bo->read_shared(array_keys($id2key), false) as $id => $shared) + { + $contacts[$id2key[$id]]['shared'] = $shared; + if (!$this->bo->check_perms(Acl::READ, $contact, false, $this->user)) + { + $contacts[$id2key[$id]]['tid'] = Api\Contacts::DELETED_TYPE; + } + } + } + foreach($contacts as &$contact) { // remove contact from requested multiget ids, to be able to report not found urls diff --git a/addressbook/inc/class.addressbook_ui.inc.php b/addressbook/inc/class.addressbook_ui.inc.php index 4c72aedceb..06966dffb9 100644 --- a/addressbook/inc/class.addressbook_ui.inc.php +++ b/addressbook/inc/class.addressbook_ui.inc.php @@ -73,6 +73,11 @@ class addressbook_ui extends addressbook_bo */ protected $tmpl; + /** + * @var array + */ + public $grouped_views; + /** * Constructor * @@ -88,7 +93,8 @@ class addressbook_ui extends addressbook_bo 'org_name' => lang('Organisations'), 'org_name,adr_one_locality' => lang('Organisations by location'), 'org_name,org_unit' => lang('Organisations by departments'), - 'duplicates' => lang('Duplicates') + 'duplicates' => lang('Duplicates'), + 'shared_by_me' => lang('Shared by me'), ); // make sure the hook for export_limit is registered @@ -557,6 +563,36 @@ class addressbook_ui extends addressbook_bo 'hideOnMobile' => true ); } + if (($share2addressbooks = $this->get_addressbooks(Acl::EDIT|self::ACL_SHARED, null, null, false))) + { + unset($share2addressbooks[0]); // do not offer action to share contact into accounts AB + foreach ($share2addressbooks as $owner => $label) + { + if (substr($owner, -1) === 'p') // share with private AB makes no sense + { + unset($share2addressbooks[$owner]); + continue; + } + $icon = $type_label = null; + $this->type_icon((int)$owner, substr($owner, -1) == 'p', 'n', $icon, $type_label); + $share2addressbooks[$owner] = array( + 'icon' => $icon, + 'caption' => $label, + ); + } + $actions['shared_with'] = [ + 'caption' => 'Share into addressbook', + 'children' => [ + 'writable' => [ + 'id' => 'writable', + 'caption' => 'Share writable', + 'checkbox' => true, + ]] + $share2addressbooks, + 'prefix' => 'shared_with_', + 'group' => $group, + 'hideOnMobile' => true + ]; + } $actions['change_type'] = $this->change_type_actions($group); $actions['merge'] = array( 'caption' => 'Merge contacts', @@ -1216,6 +1252,11 @@ class addressbook_ui extends addressbook_bo $cat_id = (int)substr($action, 8); $action = substr($action,0,7); } + elseif(substr($action, 0, 12) === 'shared_with_') + { + $shared_with = substr($action, 12); + $action = 'shared_with'; + } // Security: stop non-admins to export more then the configured number of contacts if (in_array($action,array('csv','vcard')) && $this->config['contact_export_limit'] && !Api\Storage\Merge::is_export_limit_excepted() && (!is_numeric($this->config['contact_export_limit']) || count($checked) > $this->config['contact_export_limit'])) @@ -1425,6 +1466,30 @@ class addressbook_ui extends addressbook_bo } } break; + case 'shared_with': + $action_msg = lang('shared into addressbook %1', Accounts::username($shared_with)); + if (($Ok = !!($contact = $this->read($id)))) + { + $new_shared_with = [[ + 'shared_with' => $shared_with, + 'shared_by' => $this->user, + 'shared_at' => new Api\DateTime('now'), + // only allow to share writable, if user has edit-rights! + 'shared_writable' => (int)($checkboxes['writable'] && $this->check_perms(Acl::EDIT, $contact)), + ]]; + if ($this->check_shared_with($new_shared_with)) // returns [] if OK + { + $Ok = false; + } + else + { + $contact['shared'][] = $new_shared_with[0]; + // we might need to ignore acl, as we are allowed to share with just read-rights + // setting user and update-time is explicitly desired for sync(-collection)! + $Ok = $this->save($contact, true); + } + } + break; default: // move to an other addressbook if (!(int)$action || !($this->grants[(string) (int) $action] & Acl::EDIT)) // might be ADD in the future { @@ -1596,6 +1661,13 @@ class addressbook_ui extends addressbook_bo $old_state = Api\Cache::getSession('addressbook', $what); } $GLOBALS['egw']->session->commit_session(); + + if ($query['grouped_view'] === 'shared_by_me') + { + $query['col_filter']['shared_by'] = $this->user; + $query['grouped_view'] = ''; + } + if (!isset($this->grouped_views[(string) $query['grouped_view']]) || strpos($query['grouped_view'],':') === false) { // we don't have a grouped view, unset the according col_filters @@ -2011,6 +2083,11 @@ class addressbook_ui extends addressbook_bo } $GLOBALS['egw_info']['flags']['app_header'] = implode(': ', $header); + if ($query['grouped_view'] === '' && $query['col_filter']['shared_by'] == $this->user) + { + $query['grouped_view'] = 'shared_by_me'; + unset($query['col_filter']['shared_by']); + } return $this->total; } diff --git a/addressbook/lang/egw_de.lang b/addressbook/lang/egw_de.lang index 156c6ad945..f0aba98101 100644 --- a/addressbook/lang/egw_de.lang +++ b/addressbook/lang/egw_de.lang @@ -456,6 +456,10 @@ send succeeded to %1 common de erfolgreich versandt an seperator addressbook de Feldtrenner set full name and file as field in contacts of all users (either all or only empty values) admin de Setzt vollen Namen und eigene Sortierung in Kontakten aller Benutzer (entweder alle oder nur leere Werte) set only full name addressbook de Nur vollen Namen setzen +share into addressbook addressbook de Teilen in Adressbuch +share writable addressbook de Bearbeitbar teilen +shared by me addressbook de Von mir geteilt +shared into addressbook %1 addressbook de geteilt in Adressbuch %1 shared with addressbook de Geteilt mit should the columns photo and home address always be displayed, even if they are empty. addressbook de Sollen die Spalten Foto und Privatadresse immer angezeigt werden, auch wenn sie leer sind? show addressbook de Anzeigen @@ -553,6 +557,7 @@ yes, for the next week addressbook de Ja, für die nächste Woche yes, for today and tomorrow addressbook de Ja, für heute und morgen yes, only admins can purge deleted items admin de Ja, nur Administratoren können gelöschte Einträge bereinigen yes, users can purge their deleted items admin de Benutzer können Ihre eigenen Datensätze wieder herstellen (z.B. persönliches Adressbuch) +you are not allowed to share into the addressbook of %1 addressbook de Sie dürfen nicht in das Adressbuch von %1 teilen you are not permitted to delete contact %1 addressbook de Sie haben keine Berechtigungen um den Kontakt %1 zu löschen you are not permittet to delete this contact addressbook de Sie haben keine Berechtigung diesen Kontakt zu löschen you are not permittet to edit this contact addressbook de Sie haben keine Berechtigung diesen Kontakt zu bearbeiten diff --git a/addressbook/lang/egw_en.lang b/addressbook/lang/egw_en.lang index 621cb6251b..bfd7aaab57 100644 --- a/addressbook/lang/egw_en.lang +++ b/addressbook/lang/egw_en.lang @@ -456,6 +456,10 @@ send succeeded to %1 common en Send succeeded to %1 seperator addressbook en Separator set full name and file as field in contacts of all users (either all or only empty values) admin en Set full name and 'fileas' field in contacts of all users. Either all or only empty values. set only full name addressbook en Set only full name +share into addressbook addressbook en Share into addressbook +share writable addressbook en Share writable +shared by me addressbook en Shared by me +shared into addressbook %1 addressbook en shared into addressbook %1 shared with addressbook en Shared with should the columns photo and home address always be displayed, even if they are empty. addressbook en Are photo and home address always displayed, even if columns are empty. show addressbook en Show @@ -553,6 +557,7 @@ yes, for the next week addressbook en Yes, for the next week yes, for today and tomorrow addressbook en Yes, for today and tomorrow yes, only admins can purge deleted items admin en Yes, only admins can purge deleted items yes, users can purge their deleted items admin en Yes, users can purge their deleted items +you are not allowed to share into the addressbook of %1 addressbook en You are not allowed to share into the addressbook of %1 you are not permitted to delete contact %1 addressbook en You are not permitted to delete contact %1 you are not permittet to delete this contact addressbook en You are not permitted to delete this contact you are not permittet to edit this contact addressbook en You are not permitted to edit this contact diff --git a/api/src/Contacts.php b/api/src/Contacts.php index 3c5223e660..60a733e499 100755 --- a/api/src/Contacts.php +++ b/api/src/Contacts.php @@ -358,11 +358,12 @@ class Contacts extends Contacts\Storage * * @param int $required =Acl::READ required rights on the addressbook or multiple rights or'ed together, * to return only addressbooks fullfilling all the given rights - * @param string $extra_label first label if given (already translated) - * @param int $user =null account_id or null for current user + * @param ?string $extra_label first label if given (already translated) + * @param ?int $user =null account_id or null for current user + * @param boolean $check_all =true false: only require any of the given right-bits is set * @return array with owner => label pairs */ - function get_addressbooks($required=Acl::READ,$extra_label=null,$user=null) + function get_addressbooks($required=Acl::READ,$extra_label=null,$user=null,$check_all=true) { if (is_null($user)) { @@ -383,7 +384,7 @@ class Contacts extends Contacts\Storage // add all group addressbooks the user has the necessary rights too foreach($grants as $uid => $rights) { - if (($rights & $required) == $required && $GLOBALS['egw']->accounts->get_type($uid) == 'g') + if (self::is_set($rights, $required, $check_all) && $GLOBALS['egw']->accounts->get_type($uid) == 'g') { $to_sort[$uid] = lang('Group %1',$GLOBALS['egw']->accounts->id2name($uid)); } @@ -405,7 +406,7 @@ class Contacts extends Contacts\Storage $to_sort = array(); foreach($grants as $uid => $rights) { - if ($uid != $user && ($rights & $required) == $required && $GLOBALS['egw']->accounts->get_type($uid) == 'u') + if ($uid != $user && self::is_set($rights, $required, $check_all) && $GLOBALS['egw']->accounts->get_type($uid) == 'u') { $to_sort[$uid] = Accounts::username($uid); } @@ -422,6 +423,19 @@ class Contacts extends Contacts\Storage return $addressbooks; } + /** + * Check rights for one or more required rights + * @param int $rights + * @param int $required + * @param boolean $check_all =true false: only require any of the given right-bits is set + * @return bool + */ + private static function is_set($rights, $required, $check_all=true) + { + $result = $rights & $required; + return $check_exact ? $result == $required : $result !== 0; + } + /** * calculate the file_as string from the contact and the file_as type * @@ -1193,10 +1207,12 @@ class Contacts extends Contacts\Storage * @param int $needed necessary ACL right: Acl::{READ|EDIT|DELETE} * @param mixed $contact contact as array or the contact-id * @param boolean $deny_account_delete =false if true never allow to delete accounts - * @param int $user =null for which user to check, default current user - * @return boolean true permission granted, false for permission denied, null for contact does not exist + * @param ?int $user =null for which user to check, default current user + * @param int $check_shared =3 limits the nesting level of sharing checks, use 0 to NOT check sharing + * @return ?boolean|"shared" true permission granted, false for permission denied, null for contact does not exist + * "shared" if permission is from sharing */ - function check_perms($needed,$contact,$deny_account_delete=false,$user=null) + function check_perms($needed,$contact,$deny_account_delete=false,$user=null,$check_shared=3) { if (!$user) $user = $this->user; if ($user == $this->user) @@ -1245,19 +1261,21 @@ class Contacts extends Contacts\Storage (!$contact['private'] || ($grants[$owner] & Acl::PRIVAT) || in_array($owner,$memberships)); } // check if we might have access via sharing (not for delete) - if ($access === false && !empty($contact['shared']) && $needed != Acl::DELETE) + if ($access === false && !empty($contact['shared']) && $needed != Acl::DELETE && $check_shared > 0) { foreach($contact['shared'] as $shared) { - if (isset($grants[$shared['shared_with']]) && ($shared['shared_writable'] || !($needed & Acl::EDIT))) + if (isset($grants[$shared['shared_with']]) && (!($needed & Acl::EDIT) || + // if shared writable, we check if the one who shared the contact still has edit rights + $shared['shared_writable'] && $this->check_perms($needed, $contact, $deny_account_delete, $shared['shared_by'], $check_shared-1))) { - $access = true; - error_log(__METHOD__."($needed,$contact[id],$deny_account_delete,$user) shared=".json_encode($shared)." returning ".array2string($access)); + $access = "shared"; + error_log(__METHOD__."($needed,$contact[id],$deny_account_delete,$user,$check_shared) shared=".json_encode($shared)." returning ".array2string($access)); break; } } } - //error_log(__METHOD__."($needed,$contact[id],$deny_account_delete,$user) returning ".array2string($access)); + //error_log(__METHOD__."($needed,$contact[id],$deny_account_delete,$user,$check_shared) returning ".array2string($access)); return $access; } @@ -1265,7 +1283,7 @@ class Contacts extends Contacts\Storage * Check if user has right to share with / into given AB * * @param array[]& $shared_with array of arrays with values for keys "shared_with", "shared_by", ... - * @return array of entries removed from $shared_with because current user is not allowed to share into (key is preserved) + * @return array entries removed from $shared_with because current user is not allowed to share into (key is preserved) */ function check_shared_with(array &$shared_with=null) { diff --git a/api/src/Contacts/Sql.php b/api/src/Contacts/Sql.php index 11c1c98254..3509c31004 100644 --- a/api/src/Contacts/Sql.php +++ b/api/src/Contacts/Sql.php @@ -499,10 +499,22 @@ class Sql extends Api\Storage unset($filter['cat_id']); } - // SQL to get all shared contacts to be OR-ed into ACL filter - // ToDo: do we need a sharing filter for $ignore_acl - $shared_sql = 'contact_id IN (SELECT contact_id FROM '.self::SHARED_TABLE.' WHERE '. - $this->db->expression(self::SHARED_TABLE, ['shared_with' => $filter['owner'] ?? array_keys($this->grants)]).')'; + if (!empty($filter['shared_by'])) + { + $filter[] = $this->table_name.'.contact_id IN (SELECT DISTINCT contact_id FROM '.self::SHARED_TABLE.' WHERE '. + 'shared_deleted IS NULL AND shared_by='.(int)$filter['shared_by']. + ((string)$filter['owner'] !== '' ? ' AND shared_with='.(int)$filter['owner'] : '').')'; + unset($filter['shared_by']); + $shared_sql = '1=1'; // can not be empty and must be true + } + else + { + // SQL to get all shared contacts to be OR-ed into ACL filter + $shared_sql = 'contact_id IN (SELECT contact_id FROM '.self::SHARED_TABLE.' WHERE '. + // $filter[tid] === null is used by sync-collection report, in which case we need to return deleted shares, to remove them from devices + (array_key_exists('tid', $filter) && !isset($filter['tid']) ? '' : 'shared_deleted IS NULL AND '). + $this->db->expression(self::SHARED_TABLE, ['shared_with' => $filter['owner'] ?? array_keys($this->grants)]).')'; + } // add filter for read ACL in sql, if user is NOT the owner of the addressbook if (isset($this->grants) && !$ignore_acl) @@ -1036,13 +1048,15 @@ class Sql extends Api\Storage * Read sharing information of a contact * * @param int $id contact_id to read - * @return array of array with values for keys "shared_(with|writable|by|at|id)" + * @param ?boolean $deleted =false false: ignore deleted, true: only deleted, null: both + * @return array of array with values for keys "shared_(with|writable|by|at|id|deleted)" */ - function read_shared($id) + function read_shared($id, $deleted=false) { $shared = []; - foreach($this->db->select(self::SHARED_TABLE, '*', ['contact_id' => $id], - __LINE__, __FILE__, false) as $row) + $where = ['contact_id' => $id]; + if (isset($deleted)) $where[] = $deleted ? 'shared_deleted IS NOT NULL' : 'shared_deleted IS NULL'; + foreach($this->db->select(self::SHARED_TABLE, '*', $where, __LINE__, __FILE__, false) as $row) { $row['shared_at'] = Api\DateTime::server2user($row['shared_at'], 'object'); $shared[] = $row; @@ -1051,6 +1065,8 @@ class Sql extends Api\Storage } /** + * Save sharing information of a contact + * * @param int $id * @param array $shared array of array with values for keys "shared_(with|writable|by|at|id)" * @return array of array with values for keys "shared_(with|writable|by|at|id)" @@ -1058,7 +1074,7 @@ class Sql extends Api\Storage function save_shared($id, array $shared) { $ids = []; - foreach($shared as &$data) + foreach($shared as $key => &$data) { if (empty($data['shared_id'])) { @@ -1066,14 +1082,32 @@ class Sql extends Api\Storage $data['contact_id'] = $id; $data['shared_at'] = Api\DateTime::user2server($data['shared_at'] ?: 'now'); $data['shared_by'] = $data['shared_by'] ?: $GLOBALS['egw_info']['user']['account_id']; - $this->db->insert(self::SHARED_TABLE, $data, false, __LINE__, __FILE__); + $data['shared_deleted'] = null; + foreach($shared as $ckey => $check) + { + if (!empty($check['shared_id']) && + $data['shared_with'] == $check['shared_with'] && + $data['shared_by'] == $check['shared_by']) + { + if ($data['shared_writable'] == $check['shared_writable']) + { + unset($shared[$key]); + continue 2; // no need to save identical entry + } + // remove + unset($shared[$ckey]); + break; + } + } + $this->db->insert(self::SHARED_TABLE, $data, + array_intersect_key($data, array_flip(['shared_by','shared_with','contact_id','share_id'])), __LINE__, __FILE__); $data['shared_id'] = $this->db->get_last_insert_id(self::SHARED_TABLE, 'share_id'); } $ids[] = (int)$data['shared_id']; } - $delete = ['contact_id' => $id]; + $delete = ['contact_id' => $id, 'shared_deleted IS NULL']; if ($ids) $delete[] = 'shared_id NOT IN ('.implode(',', $ids).')'; - $this->db->delete(self::SHARED_TABLE, $delete, __LINE__, __FILE__); + $this->db->update(self::SHARED_TABLE, ['shared_deleted' => new Api\DateTime('now')], $delete, __LINE__, __FILE__); foreach($shared as &$data) { $data['shared_at'] = Api\DateTime::server2user($data['shared_at'], 'object'); @@ -1081,6 +1115,32 @@ class Sql extends Api\Storage return $shared; } + /** + * deletes row representing keys in internal data or the supplied $keys if != null + * + * reimplented to also delete sharing info + * + * @param array|int $keys =null if given array with col => value pairs to characterise the rows to delete, or integer autoinc id + * @param boolean $only_return_ids =false return $ids of delete call to db object, but not run it (can be used by extending classes!) + * @return int|array affected rows, should be 1 if ok, 0 if an error or array with id's if $only_return_ids + */ + function delete($keys=null,$only_return_ids=false) + { + if (!$only_return_ids) + { + if (is_scalar($keys)) + { + $query = ['contact_id' => $keys]; + } + elseif (!isset($keys['contact_id'])) + { + $query = parent::delete($keys,true); + } + $this->db->delete(self::SHARED_TABLE, $query ?? $keys, __LINE__, __FILE__); + } + return parent::delete($keys, $only_return_ids); + } + /** * Saves a contact, reimplemented to check a given etag and set a uid * diff --git a/api/src/Contacts/Storage.php b/api/src/Contacts/Storage.php index 1b9a07a173..70f12af7ad 100755 --- a/api/src/Contacts/Storage.php +++ b/api/src/Contacts/Storage.php @@ -618,6 +618,24 @@ class Storage return $this->db2data($contact); } + /** + * @param array $ids + * @param ?boolean $deleted false: no deleted, true: only deleted, null: both + * @return array contact_id => array of array with sharing info + */ + function read_shared(array $ids, $deleted=false) + { + $contacts = []; + if (method_exists($backend = $this->get_backend($ids[0]), 'read_shared')) + { + foreach($backend->read_shared($ids, $deleted) as $shared) + { + $contacts[$shared['contact_id']][] = $shared; + } + } + return $contacts; + } + /** * searches db for rows matching searchcriteria *