WIP contact sharing: context menu to share and filter for shared contacts

This commit is contained in:
Ralf Becker 2020-10-16 21:34:26 +02:00
parent 38ff63f778
commit 37be9f40d0
7 changed files with 235 additions and 27 deletions

View File

@ -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

View File

@ -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;
}

View File

@ -455,6 +455,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
@ -552,6 +556,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

View File

@ -455,6 +455,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
@ -552,6 +556,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

View File

@ -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)
{

View File

@ -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
*

View File

@ -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
*