RoundCube Webmail
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2157 lines
77 KiB

13 years ago
10 years ago
17 years ago
13 years ago
11 years ago
10 years ago
17 years ago
11 years ago
18 years ago
18 years ago
18 years ago
17 years ago
10 years ago
10 years ago
13 years ago
14 years ago
11 years ago
11 years ago
17 years ago
17 years ago
17 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
11 years ago
  1. <?php
  2. /**
  3. +-----------------------------------------------------------------------+
  4. | This file is part of the Roundcube Webmail client |
  5. | Copyright (C) 2006-2013, The Roundcube Dev Team |
  6. | Copyright (C) 2011-2013, Kolab Systems AG |
  7. | |
  8. | Licensed under the GNU General Public License version 3 or |
  9. | any later version with exceptions for skins & plugins. |
  10. | See the README file for a full license statement. |
  11. | |
  12. | PURPOSE: |
  13. | Interface to an LDAP address directory |
  14. +-----------------------------------------------------------------------+
  15. | Author: Thomas Bruederli <roundcube@gmail.com> |
  16. | Andreas Dick <andudi (at) gmx (dot) ch> |
  17. | Aleksander Machniak <machniak@kolabsys.com> |
  18. +-----------------------------------------------------------------------+
  19. */
  20. /**
  21. * Model class to access an LDAP address directory
  22. *
  23. * @package Framework
  24. * @subpackage Addressbook
  25. */
  26. class rcube_ldap extends rcube_addressbook
  27. {
  28. // public properties
  29. public $primary_key = 'ID';
  30. public $groups = false;
  31. public $readonly = true;
  32. public $ready = false;
  33. public $group_id = 0;
  34. public $coltypes = array();
  35. public $export_groups = false;
  36. // private properties
  37. protected $ldap;
  38. protected $formats = array();
  39. protected $prop = array();
  40. protected $fieldmap = array();
  41. protected $filter = '';
  42. protected $sub_filter;
  43. protected $result;
  44. protected $ldap_result;
  45. protected $mail_domain = '';
  46. protected $debug = false;
  47. /**
  48. * Group objectclass (lowercase) to member attribute mapping
  49. *
  50. * @var array
  51. */
  52. private $group_types = array(
  53. 'group' => 'member',
  54. 'groupofnames' => 'member',
  55. 'kolabgroupofnames' => 'member',
  56. 'groupofuniquenames' => 'uniqueMember',
  57. 'kolabgroupofuniquenames' => 'uniqueMember',
  58. 'univentiongroup' => 'uniqueMember',
  59. 'groupofurls' => null,
  60. );
  61. private $base_dn = '';
  62. private $groups_base_dn = '';
  63. private $group_data;
  64. private $group_search_cache;
  65. private $cache;
  66. /**
  67. * Object constructor
  68. *
  69. * @param array $p LDAP connection properties
  70. * @param boolean $debug Enables debug mode
  71. * @param string $mail_domain Current user mail domain name
  72. */
  73. function __construct($p, $debug = false, $mail_domain = null)
  74. {
  75. $this->prop = $p;
  76. $fetch_attributes = array('objectClass');
  77. // check if groups are configured
  78. if (is_array($p['groups']) && count($p['groups'])) {
  79. $this->groups = true;
  80. // set member field
  81. if (!empty($p['groups']['member_attr']))
  82. $this->prop['member_attr'] = strtolower($p['groups']['member_attr']);
  83. else if (empty($p['member_attr']))
  84. $this->prop['member_attr'] = 'member';
  85. // set default name attribute to cn
  86. if (empty($this->prop['groups']['name_attr']))
  87. $this->prop['groups']['name_attr'] = 'cn';
  88. if (empty($this->prop['groups']['scope']))
  89. $this->prop['groups']['scope'] = 'sub';
  90. // extend group objectclass => member attribute mapping
  91. if (!empty($this->prop['groups']['class_member_attr']))
  92. $this->group_types = array_merge($this->group_types, $this->prop['groups']['class_member_attr']);
  93. // add group name attrib to the list of attributes to be fetched
  94. $fetch_attributes[] = $this->prop['groups']['name_attr'];
  95. }
  96. if (is_array($p['group_filters'])) {
  97. $this->groups = $this->groups || count($p['group_filters']);
  98. foreach ($p['group_filters'] as $k => $group_filter) {
  99. // set default name attribute to cn
  100. if (empty($group_filter['name_attr']) && empty($this->prop['groups']['name_attr']))
  101. $this->prop['group_filters'][$k]['name_attr'] = $group_filter['name_attr'] = 'cn';
  102. if ($group_filter['name_attr'])
  103. $fetch_attributes[] = $group_filter['name_attr'];
  104. }
  105. }
  106. // fieldmap property is given
  107. if (is_array($p['fieldmap'])) {
  108. $p['fieldmap'] = array_filter($p['fieldmap']);
  109. foreach ($p['fieldmap'] as $rf => $lf)
  110. $this->fieldmap[$rf] = $this->_attr_name($lf);
  111. }
  112. else if (!empty($p)) {
  113. // read deprecated *_field properties to remain backwards compatible
  114. foreach ($p as $prop => $value)
  115. if (!empty($value) && preg_match('/^(.+)_field$/', $prop, $matches))
  116. $this->fieldmap[$matches[1]] = $this->_attr_name($value);
  117. }
  118. // use fieldmap to advertise supported coltypes to the application
  119. foreach ($this->fieldmap as $colv => $lfv) {
  120. list($col, $type) = explode(':', $colv);
  121. $params = explode(':', $lfv);
  122. $lf = array_shift($params);
  123. $limit = 1;
  124. foreach ($params as $idx => $param) {
  125. // field format specification
  126. if (preg_match('/^(date)\[(.+)\]$/i', $param, $m)) {
  127. $this->formats[$lf] = array('type' => strtolower($m[1]), 'format' => $m[2]);
  128. }
  129. // first argument is a limit
  130. else if ($idx === 0) {
  131. if ($param == '*') $limit = null;
  132. else $limit = max(1, intval($param));
  133. }
  134. // second is a composite field separator
  135. else if ($idx === 1 && $param) {
  136. $this->coltypes[$col]['serialized'][$type] = $param;
  137. }
  138. }
  139. if (!is_array($this->coltypes[$col])) {
  140. $subtypes = $type ? array($type) : null;
  141. $this->coltypes[$col] = array('limit' => $limit, 'subtypes' => $subtypes, 'attributes' => array($lf));
  142. }
  143. elseif ($type) {
  144. $this->coltypes[$col]['subtypes'][] = $type;
  145. $this->coltypes[$col]['attributes'][] = $lf;
  146. $this->coltypes[$col]['limit'] += $limit;
  147. }
  148. $this->fieldmap[$colv] = $lf;
  149. }
  150. // support for composite address
  151. if ($this->coltypes['street'] && $this->coltypes['locality']) {
  152. $this->coltypes['address'] = array(
  153. 'limit' => max(1, $this->coltypes['locality']['limit'] + $this->coltypes['address']['limit']),
  154. 'subtypes' => array_merge((array)$this->coltypes['address']['subtypes'], (array)$this->coltypes['locality']['subtypes']),
  155. 'childs' => array(),
  156. 'attributes' => array(),
  157. ) + (array)$this->coltypes['address'];
  158. foreach (array('street','locality','zipcode','region','country') as $childcol) {
  159. if ($this->coltypes[$childcol]) {
  160. $this->coltypes['address']['childs'][$childcol] = array('type' => 'text');
  161. $this->coltypes['address']['attributes'] = array_merge($this->coltypes['address']['attributes'], $this->coltypes[$childcol]['attributes']);
  162. unset($this->coltypes[$childcol]); // remove address child col from global coltypes list
  163. }
  164. }
  165. // at least one address type must be specified
  166. if (empty($this->coltypes['address']['subtypes'])) {
  167. $this->coltypes['address']['subtypes'] = array('home');
  168. }
  169. }
  170. else if ($this->coltypes['address']) {
  171. $this->coltypes['address'] += array('type' => 'textarea', 'childs' => null, 'size' => 40);
  172. // 'serialized' means the UI has to present a composite address field
  173. if ($this->coltypes['address']['serialized']) {
  174. $childprop = array('type' => 'text');
  175. $this->coltypes['address']['type'] = 'composite';
  176. $this->coltypes['address']['childs'] = array('street' => $childprop, 'locality' => $childprop, 'zipcode' => $childprop, 'country' => $childprop);
  177. }
  178. }
  179. // make sure 'required_fields' is an array
  180. if (!is_array($this->prop['required_fields'])) {
  181. $this->prop['required_fields'] = (array) $this->prop['required_fields'];
  182. }
  183. // make sure LDAP_rdn field is required
  184. if (!empty($this->prop['LDAP_rdn']) && !in_array($this->prop['LDAP_rdn'], $this->prop['required_fields'])
  185. && !in_array($this->prop['LDAP_rdn'], array_keys((array)$this->prop['autovalues']))) {
  186. $this->prop['required_fields'][] = $this->prop['LDAP_rdn'];
  187. }
  188. foreach ($this->prop['required_fields'] as $key => $val) {
  189. $this->prop['required_fields'][$key] = $this->_attr_name($val);
  190. }
  191. // Build sub_fields filter
  192. if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
  193. $this->sub_filter = '';
  194. foreach ($this->prop['sub_fields'] as $class) {
  195. if (!empty($class)) {
  196. $class = is_array($class) ? array_pop($class) : $class;
  197. $this->sub_filter .= '(objectClass=' . $class . ')';
  198. }
  199. }
  200. if (count($this->prop['sub_fields']) > 1) {
  201. $this->sub_filter = '(|' . $this->sub_filter . ')';
  202. }
  203. }
  204. $this->sort_col = is_array($p['sort']) ? $p['sort'][0] : $p['sort'];
  205. $this->debug = $debug;
  206. $this->mail_domain = $this->prop['mail_domain'] = $mail_domain;
  207. // initialize cache
  208. $rcube = rcube::get_instance();
  209. if ($cache_type = $rcube->config->get('ldap_cache', 'db')) {
  210. $cache_ttl = $rcube->config->get('ldap_cache_ttl', '10m');
  211. $cache_name = 'LDAP.' . asciiwords($this->prop['name']);
  212. $this->cache = $rcube->get_cache($cache_name, $cache_type, $cache_ttl);
  213. }
  214. // determine which attributes to fetch
  215. $this->prop['list_attributes'] = array_unique($fetch_attributes);
  216. $this->prop['attributes'] = array_merge(array_values($this->fieldmap), $fetch_attributes);
  217. foreach ($rcube->config->get('contactlist_fields') as $col) {
  218. $this->prop['list_attributes'] = array_merge($this->prop['list_attributes'], $this->_map_field($col));
  219. }
  220. // initialize ldap wrapper object
  221. $this->ldap = new rcube_ldap_generic($this->prop);
  222. $this->ldap->config_set(array('cache' => $this->cache, 'debug' => $this->debug));
  223. $this->_connect();
  224. }
  225. /**
  226. * Establish a connection to the LDAP server
  227. */
  228. private function _connect()
  229. {
  230. $rcube = rcube::get_instance();
  231. if ($this->ready) {
  232. return true;
  233. }
  234. if (!is_array($this->prop['hosts'])) {
  235. $this->prop['hosts'] = array($this->prop['hosts']);
  236. }
  237. // try to connect + bind for every host configured
  238. // with OpenLDAP 2.x ldap_connect() always succeeds but ldap_bind will fail if host isn't reachable
  239. // see http://www.php.net/manual/en/function.ldap-connect.php
  240. foreach ($this->prop['hosts'] as $host) {
  241. // skip host if connection failed
  242. if (!$this->ldap->connect($host)) {
  243. continue;
  244. }
  245. // See if the directory is writeable.
  246. if ($this->prop['writable']) {
  247. $this->readonly = false;
  248. }
  249. $bind_pass = $this->prop['bind_pass'];
  250. $bind_user = $this->prop['bind_user'];
  251. $bind_dn = $this->prop['bind_dn'];
  252. $this->base_dn = $this->prop['base_dn'];
  253. $this->groups_base_dn = $this->prop['groups']['base_dn'] ?: $this->base_dn;
  254. // User specific access, generate the proper values to use.
  255. if ($this->prop['user_specific']) {
  256. // No password set, use the session password
  257. if (empty($bind_pass)) {
  258. $bind_pass = $rcube->get_user_password();
  259. }
  260. // Get the pieces needed for variable replacement.
  261. if ($fu = ($rcube->get_user_email() ?: $this->prop['username'])) {
  262. list($u, $d) = explode('@', $fu);
  263. }
  264. else {
  265. $d = $this->mail_domain;
  266. }
  267. $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
  268. // resolve $dc through LDAP
  269. if (!empty($this->prop['domain_filter']) && !empty($this->prop['search_bind_dn']) &&
  270. method_exists($this->ldap, 'domain_root_dn')) {
  271. $this->ldap->bind($this->prop['search_bind_dn'], $this->prop['search_bind_pw']);
  272. $dc = $this->ldap->domain_root_dn($d);
  273. }
  274. $replaces = array('%dn' => '', '%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
  275. // Search for the dn to use to authenticate
  276. if ($this->prop['search_base_dn'] && $this->prop['search_filter']
  277. && (strstr($bind_dn, '%dn') || strstr($this->base_dn, '%dn') || strstr($this->groups_base_dn, '%dn'))
  278. ) {
  279. $search_attribs = array('uid');
  280. if ($search_bind_attrib = (array)$this->prop['search_bind_attrib']) {
  281. foreach ($search_bind_attrib as $r => $attr) {
  282. $search_attribs[] = $attr;
  283. $replaces[$r] = '';
  284. }
  285. }
  286. $search_bind_dn = strtr($this->prop['search_bind_dn'], $replaces);
  287. $search_base_dn = strtr($this->prop['search_base_dn'], $replaces);
  288. $search_filter = strtr($this->prop['search_filter'], $replaces);
  289. $cache_key = 'DN.' . md5("$host:$search_bind_dn:$search_base_dn:$search_filter:"
  290. .$this->prop['search_bind_pw']);
  291. if ($this->cache && ($dn = $this->cache->get($cache_key))) {
  292. $replaces['%dn'] = $dn;
  293. }
  294. else {
  295. $ldap = $this->ldap;
  296. if (!empty($search_bind_dn) && !empty($this->prop['search_bind_pw'])) {
  297. // To protect from "Critical extension is unavailable" error
  298. // we need to use a separate LDAP connection
  299. if (!empty($this->prop['vlv'])) {
  300. $ldap = new rcube_ldap_generic($this->prop);
  301. $ldap->config_set(array('cache' => $this->cache, 'debug' => $this->debug));
  302. if (!$ldap->connect($host)) {
  303. continue;
  304. }
  305. }
  306. if (!$ldap->bind($search_bind_dn, $this->prop['search_bind_pw'])) {
  307. continue; // bind failed, try next host
  308. }
  309. }
  310. $res = $ldap->search($search_base_dn, $search_filter, 'sub', $search_attribs);
  311. if ($res) {
  312. $res->rewind();
  313. $replaces['%dn'] = key($res->entries(TRUE));
  314. // add more replacements from 'search_bind_attrib' config
  315. if ($search_bind_attrib) {
  316. $res = $res->current();
  317. foreach ($search_bind_attrib as $r => $attr) {
  318. $replaces[$r] = $res[$attr][0];
  319. }
  320. }
  321. }
  322. if ($ldap != $this->ldap) {
  323. $ldap->close();
  324. }
  325. }
  326. // DN not found
  327. if (empty($replaces['%dn'])) {
  328. if (!empty($this->prop['search_dn_default']))
  329. $replaces['%dn'] = $this->prop['search_dn_default'];
  330. else {
  331. rcube::raise_error(array(
  332. 'code' => 100, 'type' => 'ldap',
  333. 'file' => __FILE__, 'line' => __LINE__,
  334. 'message' => "DN not found using LDAP search."), true);
  335. continue;
  336. }
  337. }
  338. if ($this->cache && !empty($replaces['%dn'])) {
  339. $this->cache->set($cache_key, $replaces['%dn']);
  340. }
  341. }
  342. // Replace the bind_dn and base_dn variables.
  343. $bind_dn = strtr($bind_dn, $replaces);
  344. $this->base_dn = strtr($this->base_dn, $replaces);
  345. $this->groups_base_dn = strtr($this->groups_base_dn, $replaces);
  346. // replace placeholders in filter settings
  347. if (!empty($this->prop['filter']))
  348. $this->prop['filter'] = strtr($this->prop['filter'], $replaces);
  349. foreach (array('base_dn','filter','member_filter') as $k) {
  350. if (!empty($this->prop['groups'][$k]))
  351. $this->prop['groups'][$k] = strtr($this->prop['groups'][$k], $replaces);
  352. }
  353. if (is_array($this->prop['group_filters'])) {
  354. foreach ($this->prop['group_filters'] as $i => $gf) {
  355. if (!empty($gf['base_dn']))
  356. $this->prop['group_filters'][$i]['base_dn'] = strtr($gf['base_dn'], $replaces);
  357. if (!empty($gf['filter']))
  358. $this->prop['group_filters'][$i]['filter'] = strtr($gf['filter'], $replaces);
  359. }
  360. }
  361. if (empty($bind_user)) {
  362. $bind_user = $u;
  363. }
  364. }
  365. if (empty($bind_pass)) {
  366. $this->ready = true;
  367. }
  368. else {
  369. if (!empty($bind_dn)) {
  370. $this->ready = $this->ldap->bind($bind_dn, $bind_pass);
  371. }
  372. else if (!empty($this->prop['auth_cid'])) {
  373. $this->ready = $this->ldap->sasl_bind($this->prop['auth_cid'], $bind_pass, $bind_user);
  374. }
  375. else {
  376. $this->ready = $this->ldap->sasl_bind($bind_user, $bind_pass);
  377. }
  378. }
  379. // connection established, we're done here
  380. if ($this->ready) {
  381. break;
  382. }
  383. } // end foreach hosts
  384. if (!is_resource($this->ldap->conn)) {
  385. rcube::raise_error(array('code' => 100, 'type' => 'ldap',
  386. 'file' => __FILE__, 'line' => __LINE__,
  387. 'message' => "Could not connect to any LDAP server, last tried $host"), true);
  388. return false;
  389. }
  390. return $this->ready;
  391. }
  392. /**
  393. * Close connection to LDAP server
  394. */
  395. function close()
  396. {
  397. if ($this->ldap) {
  398. $this->ldap->close();
  399. }
  400. }
  401. /**
  402. * Returns address book name
  403. *
  404. * @return string Address book name
  405. */
  406. function get_name()
  407. {
  408. return $this->prop['name'];
  409. }
  410. /**
  411. * Set internal list page
  412. *
  413. * @param number Page number to list
  414. */
  415. function set_page($page)
  416. {
  417. $this->list_page = (int)$page;
  418. $this->ldap->set_vlv_page($this->list_page, $this->page_size);
  419. }
  420. /**
  421. * Set internal page size
  422. *
  423. * @param number Number of records to display on one page
  424. */
  425. function set_pagesize($size)
  426. {
  427. $this->page_size = (int)$size;
  428. $this->ldap->set_vlv_page($this->list_page, $this->page_size);
  429. }
  430. /**
  431. * Set internal sort settings
  432. *
  433. * @param string $sort_col Sort column
  434. * @param string $sort_order Sort order
  435. */
  436. function set_sort_order($sort_col, $sort_order = null)
  437. {
  438. if ($this->coltypes[$sort_col]['attributes'])
  439. $this->sort_col = $this->coltypes[$sort_col]['attributes'][0];
  440. }
  441. /**
  442. * Save a search string for future listings
  443. *
  444. * @param string $filter Filter string
  445. */
  446. function set_search_set($filter)
  447. {
  448. $this->filter = $filter;
  449. }
  450. /**
  451. * Getter for saved search properties
  452. *
  453. * @return mixed Search properties used by this class
  454. */
  455. function get_search_set()
  456. {
  457. return $this->filter;
  458. }
  459. /**
  460. * Reset all saved results and search parameters
  461. */
  462. function reset()
  463. {
  464. $this->result = null;
  465. $this->ldap_result = null;
  466. $this->filter = '';
  467. }
  468. /**
  469. * List the current set of contact records
  470. *
  471. * @param array List of cols to show
  472. * @param int Only return this number of records
  473. *
  474. * @return array Indexed list of contact records, each a hash array
  475. */
  476. function list_records($cols=null, $subset=0)
  477. {
  478. if ($this->prop['searchonly'] && empty($this->filter) && !$this->group_id) {
  479. $this->result = new rcube_result_set(0);
  480. $this->result->searchonly = true;
  481. return $this->result;
  482. }
  483. // fetch group members recursively
  484. if ($this->group_id && $this->group_data['dn']) {
  485. $entries = $this->list_group_members($this->group_data['dn']);
  486. // make list of entries unique and sort it
  487. $seen = array();
  488. foreach ($entries as $i => $rec) {
  489. if ($seen[$rec['dn']]++)
  490. unset($entries[$i]);
  491. }
  492. usort($entries, array($this, '_entry_sort_cmp'));
  493. $entries['count'] = count($entries);
  494. $this->result = new rcube_result_set($entries['count'], ($this->list_page-1) * $this->page_size);
  495. }
  496. else {
  497. // exec LDAP search if no result resource is stored
  498. if ($this->ready && $this->ldap_result === null) {
  499. $this->ldap_result = $this->extended_search();
  500. }
  501. // count contacts for this user
  502. $this->result = $this->count();
  503. $entries = $this->ldap_result;
  504. } // end else
  505. // start and end of the page
  506. $start_row = $this->ldap->vlv_active ? 0 : $this->result->first;
  507. $start_row = $subset < 0 ? $start_row + $this->page_size + $subset : $start_row;
  508. $last_row = $this->result->first + $this->page_size;
  509. $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
  510. // filter entries for this page
  511. for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
  512. if ($entries[$i])
  513. $this->result->add($this->_ldap2result($entries[$i]));
  514. return $this->result;
  515. }
  516. /**
  517. * Get all members of the given group
  518. *
  519. * @param string Group DN
  520. * @param boolean Count only
  521. * @param array Group entries (if called recursively)
  522. * @return array Accumulated group members
  523. */
  524. function list_group_members($dn, $count = false, $entries = null)
  525. {
  526. $group_members = array();
  527. // fetch group object
  528. if (empty($entries)) {
  529. $attribs = array_merge(array('dn','objectClass','memberURL'), array_values($this->group_types));
  530. $entries = $this->ldap->read_entries($dn, '(objectClass=*)', $attribs);
  531. if ($entries === false) {
  532. return $group_members;
  533. }
  534. }
  535. for ($i=0; $i < $entries['count']; $i++) {
  536. $entry = $entries[$i];
  537. $attrs = array();
  538. foreach ((array)$entry['objectclass'] as $objectclass) {
  539. if (($member_attr = $this->get_group_member_attr(array($objectclass), ''))
  540. && ($member_attr = strtolower($member_attr)) && !in_array($member_attr, $attrs)
  541. ) {
  542. $members = $this->_list_group_members($dn, $entry, $member_attr, $count);
  543. $group_members = array_merge($group_members, $members);
  544. $attrs[] = $member_attr;
  545. }
  546. else if (!empty($entry['memberurl'])) {
  547. $members = $this->_list_group_memberurl($dn, $entry, $count);
  548. $group_members = array_merge($group_members, $members);
  549. }
  550. if ($this->prop['sizelimit'] && count($group_members) > $this->prop['sizelimit']) {
  551. break 2;
  552. }
  553. }
  554. }
  555. return array_filter($group_members);
  556. }
  557. /**
  558. * Fetch members of the given group entry from server
  559. *
  560. * @param string Group DN
  561. * @param array Group entry
  562. * @param string Member attribute to use
  563. * @param boolean Count only
  564. * @return array Accumulated group members
  565. */
  566. private function _list_group_members($dn, $entry, $attr, $count)
  567. {
  568. // Use the member attributes to return an array of member ldap objects
  569. // NOTE that the member attribute is supposed to contain a DN
  570. $group_members = array();
  571. if (empty($entry[$attr])) {
  572. return $group_members;
  573. }
  574. // read these attributes for all members
  575. $attrib = $count ? array('dn','objectClass') : $this->prop['list_attributes'];
  576. $attrib = array_merge($attrib, array_values($this->group_types));
  577. $attrib[] = 'memberURL';
  578. $filter = $this->prop['groups']['member_filter'] ?: '(objectclass=*)';
  579. for ($i=0; $i < $entry[$attr]['count']; $i++) {
  580. if (empty($entry[$attr][$i]))
  581. continue;
  582. $members = $this->ldap->read_entries($entry[$attr][$i], $filter, $attrib);
  583. if ($members == false) {
  584. $members = array();
  585. }
  586. // for nested groups, call recursively
  587. $nested_group_members = $this->list_group_members($entry[$attr][$i], $count, $members);
  588. unset($members['count']);
  589. $group_members = array_merge($group_members, array_filter($members), $nested_group_members);
  590. }
  591. return $group_members;
  592. }
  593. /**
  594. * List members of group class groupOfUrls
  595. *
  596. * @param string Group DN
  597. * @param array Group entry
  598. * @param boolean True if only used for counting
  599. * @return array Accumulated group members
  600. */
  601. private function _list_group_memberurl($dn, $entry, $count)
  602. {
  603. $group_members = array();
  604. for ($i=0; $i < $entry['memberurl']['count']; $i++) {
  605. // extract components from url
  606. if (!preg_match('!ldap://[^/]*/([^\?]+)\?\?(\w+)\?(.*)$!', $entry['memberurl'][$i], $m)) {
  607. continue;
  608. }
  609. // add search filter if any
  610. $filter = $this->filter ? '(&(' . $m[3] . ')(' . $this->filter . '))' : $m[3];
  611. $attrs = $count ? array('dn','objectClass') : $this->prop['list_attributes'];
  612. if ($result = $this->ldap->search($m[1], $filter, $m[2], $attrs, $this->group_data)) {
  613. $entries = $result->entries();
  614. for ($j = 0; $j < $entries['count']; $j++) {
  615. if ($this->is_group_entry($entries[$j]) && ($nested_group_members = $this->list_group_members($entries[$j]['dn'], $count)))
  616. $group_members = array_merge($group_members, $nested_group_members);
  617. else
  618. $group_members[] = $entries[$j];
  619. }
  620. }
  621. }
  622. return $group_members;
  623. }
  624. /**
  625. * Callback for sorting entries
  626. */
  627. function _entry_sort_cmp($a, $b)
  628. {
  629. return strcmp($a[$this->sort_col][0], $b[$this->sort_col][0]);
  630. }
  631. /**
  632. * Search contacts
  633. *
  634. * @param mixed $fields The field name of array of field names to search in
  635. * @param mixed $value Search value (or array of values when $fields is array)
  636. * @param int $mode Matching mode. Sum of rcube_addressbook::SEARCH_*
  637. * @param boolean $select True if results are requested, False if count only
  638. * @param boolean $nocount (Not used)
  639. * @param array $required List of fields that cannot be empty
  640. *
  641. * @return rcube_result_set List of contact records
  642. */
  643. function search($fields, $value, $mode=0, $select=true, $nocount=false, $required=array())
  644. {
  645. $mode = intval($mode);
  646. // special treatment for ID-based search
  647. if ($fields == 'ID' || $fields == $this->primary_key) {
  648. $ids = !is_array($value) ? explode(',', $value) : $value;
  649. $result = new rcube_result_set();
  650. foreach ($ids as $id) {
  651. if ($rec = $this->get_record($id, true)) {
  652. $result->add($rec);
  653. $result->count++;
  654. }
  655. }
  656. return $result;
  657. }
  658. // use VLV pseudo-search for autocompletion
  659. $rcube = rcube::get_instance();
  660. $list_fields = $rcube->config->get('contactlist_fields');
  661. if ($this->prop['vlv_search'] && $this->ready && join(',', (array)$fields) == join(',', $list_fields)) {
  662. $this->result = new rcube_result_set(0);
  663. $this->ldap->config_set('fuzzy_search', intval($this->prop['fuzzy_search'] && !($mode & rcube_addressbook::SEARCH_STRICT)));
  664. $ldap_data = $this->ldap->search($this->base_dn, $this->prop['filter'], $this->prop['scope'], $this->prop['attributes'],
  665. array('search' => $value /*, 'sort' => $this->prop['sort'] */));
  666. if ($ldap_data === false) {
  667. return $this->result;
  668. }
  669. // get all entries of this page and post-filter those that really match the query
  670. $search = mb_strtolower($value);
  671. foreach ($ldap_data as $entry) {
  672. $rec = $this->_ldap2result($entry);
  673. foreach ($fields as $f) {
  674. foreach ((array)$rec[$f] as $val) {
  675. if ($this->compare_search_value($f, $val, $search, $mode)) {
  676. $this->result->add($rec);
  677. $this->result->count++;
  678. break 2;
  679. }
  680. }
  681. }
  682. }
  683. return $this->result;
  684. }
  685. // advanced per-attribute search
  686. if (is_array($value)) {
  687. // use AND operator for advanced searches
  688. $filter = '(&';
  689. // set wildcards
  690. $wp = $ws = '';
  691. if (!empty($this->prop['fuzzy_search']) && !($mode & rcube_addressbook::SEARCH_STRICT)) {
  692. $ws = '*';
  693. if (!($mode & rcube_addressbook::SEARCH_PREFIX)) {
  694. $wp = '*';
  695. }
  696. }
  697. foreach ((array)$fields as $idx => $field) {
  698. $val = $value[$idx];
  699. if (!strlen($val))
  700. continue;
  701. if ($attrs = $this->_map_field($field)) {
  702. if (count($attrs) > 1)
  703. $filter .= '(|';
  704. foreach ($attrs as $f)
  705. $filter .= "($f=$wp" . rcube_ldap_generic::quote_string($val) . "$ws)";
  706. if (count($attrs) > 1)
  707. $filter .= ')';
  708. }
  709. }
  710. $filter .= ')';
  711. }
  712. else {
  713. if ($fields == '*') {
  714. // search_fields are required for fulltext search
  715. if (empty($this->prop['search_fields'])) {
  716. $this->set_error(self::ERROR_SEARCH, 'nofulltextsearch');
  717. $this->result = new rcube_result_set();
  718. return $this->result;
  719. }
  720. $attributes = (array)$this->prop['search_fields'];
  721. }
  722. else {
  723. // map address book fields into ldap attributes
  724. $attributes = array();
  725. foreach ((array) $fields as $field) {
  726. if ($this->coltypes[$field] && ($attrs = $this->coltypes[$field]['attributes'])) {
  727. $attributes = array_merge($attributes, (array) $attrs);
  728. }
  729. }
  730. }
  731. // compose a full-text-like search filter
  732. $filter = rcube_ldap_generic::fulltext_search_filter($value, $attributes, $mode & ~rcube_addressbook::SEARCH_GROUPS);
  733. }
  734. // add required (non empty) fields filter
  735. $req_filter = '';
  736. foreach ((array)$required as $field) {
  737. if (in_array($field, (array)$fields)) // required field is already in search filter
  738. continue;
  739. if ($attrs = $this->_map_field($field)) {
  740. if (count($attrs) > 1)
  741. $req_filter .= '(|';
  742. foreach ($attrs as $f)
  743. $req_filter .= "($f=*)";
  744. if (count($attrs) > 1)
  745. $req_filter .= ')';
  746. }
  747. }
  748. if (!empty($req_filter))
  749. $filter = '(&' . $req_filter . $filter . ')';
  750. // avoid double-wildcard if $value is empty
  751. $filter = preg_replace('/\*+/', '*', $filter);
  752. if ($mode & rcube_addressbook::SEARCH_GROUPS) {
  753. $filter = 'e:' . $filter;
  754. }
  755. // set filter string and execute search
  756. $this->set_search_set($filter);
  757. if ($select)
  758. $this->list_records();
  759. else
  760. $this->result = $this->count();
  761. return $this->result;
  762. }
  763. /**
  764. * Count number of available contacts in database
  765. *
  766. * @return object rcube_result_set Resultset with values for 'count' and 'first'
  767. */
  768. function count()
  769. {
  770. $count = 0;
  771. if (!empty($this->ldap_result)) {
  772. $count = $this->ldap_result['count'];
  773. }
  774. else if ($this->group_id && $this->group_data['dn']) {
  775. $count = count($this->list_group_members($this->group_data['dn'], true));
  776. }
  777. // We have a connection but no result set, attempt to get one.
  778. else if ($this->ready) {
  779. $count = $this->extended_search(true);
  780. }
  781. return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
  782. }
  783. /**
  784. * Wrapper on LDAP searches with group_filters support, which
  785. * allows searching for contacts AND groups.
  786. *
  787. * @param bool $count Return count instead of the records
  788. *
  789. * @return int|array Count of records or the result array (with 'count' item)
  790. */
  791. protected function extended_search($count = false)
  792. {
  793. $prop = $this->group_id ? $this->group_data : $this->prop;
  794. $base_dn = $this->group_id ? $prop['base_dn'] : $this->base_dn;
  795. $attrs = $count ? array('dn') : $this->prop['attributes'];
  796. $entries = array();
  797. // Use global search filter
  798. if ($filter = $this->filter) {
  799. if ($filter[0] == 'e' && $filter[1] == ':') {
  800. $filter = substr($filter, 2);
  801. $is_extended_search = !$this->group_id;
  802. }
  803. $prop['filter'] = $filter;
  804. // add general filter to query
  805. if (!empty($this->prop['filter'])) {
  806. $prop['filter'] = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $prop['filter'] . ')';
  807. }
  808. }
  809. $result = $this->ldap->search($base_dn, $prop['filter'], $prop['scope'], $attrs, $prop, $count);
  810. // we have a search result resource, get all entries
  811. if (!$count && $result) {
  812. $result_count = $result->count();
  813. $result = $result->entries();
  814. unset($result['count']);
  815. }
  816. // search for groups
  817. if ($is_extended_search
  818. && is_array($this->prop['group_filters'])
  819. && !empty($this->prop['groups']['filter'])
  820. ) {
  821. $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['groups']['filter']) . ')' . $filter . ')';
  822. // for groups we may use cn instead of displayname...
  823. if ($this->prop['fieldmap']['name'] != $this->prop['groups']['name_attr']) {
  824. $filter = str_replace(strtolower($this->prop['fieldmap']['name']) . '=', $this->prop['groups']['name_attr'] . '=', $filter);
  825. }
  826. $name_attr = $this->prop['groups']['name_attr'];
  827. $email_attr = $this->prop['groups']['email_attr'] ?: 'mail';
  828. $attrs = array_unique(array('dn', 'objectClass', $name_attr, $email_attr));
  829. $res = $this->ldap->search($this->groups_base_dn, $filter, $this->prop['groups']['scope'], $attrs, $prop, $count);
  830. if ($count && $res) {
  831. $result += $res;
  832. }
  833. else if (!$count && $res && ($res_count = $res->count())) {
  834. $res = $res->entries();
  835. unset($res['count']);
  836. $result = array_merge($result, $res);
  837. $result_count += $res_count;
  838. }
  839. }
  840. if (!$count && $result) {
  841. // sorting
  842. if ($this->sort_col && $prop['scope'] !== 'base' && !$this->ldap->vlv_active) {
  843. usort($result, array($this, '_entry_sort_cmp'));
  844. }
  845. $result['count'] = $result_count;
  846. $this->result_entries = $result;
  847. }
  848. return $result;
  849. }
  850. /**
  851. * Return the last result set
  852. *
  853. * @return object rcube_result_set Current resultset or NULL if nothing selected yet
  854. */
  855. function get_result()
  856. {
  857. return $this->result;
  858. }
  859. /**
  860. * Get a specific contact record
  861. *
  862. * @param mixed Record identifier
  863. * @param boolean Return as associative array
  864. *
  865. * @return mixed Hash array or rcube_result_set with all record fields
  866. */
  867. function get_record($dn, $assoc=false)
  868. {
  869. $res = $this->result = null;
  870. if ($this->ready && $dn) {
  871. $dn = self::dn_decode($dn);
  872. if ($rec = $this->ldap->get_entry($dn, $this->prop['attributes'])) {
  873. $rec = array_change_key_case($rec, CASE_LOWER);
  874. }
  875. // Use ldap_list to get subentries like country (c) attribute (#1488123)
  876. if (!empty($rec) && $this->sub_filter) {
  877. if ($entries = $this->ldap->list_entries($dn, $this->sub_filter, array_keys($this->prop['sub_fields']))) {
  878. foreach ($entries as $entry) {
  879. $lrec = array_change_key_case($entry, CASE_LOWER);
  880. $rec = array_merge($lrec, $rec);
  881. }
  882. }
  883. }
  884. if (!empty($rec)) {
  885. // Add in the dn for the entry.
  886. $rec['dn'] = $dn;
  887. $res = $this->_ldap2result($rec);
  888. $this->result = new rcube_result_set(1);
  889. $this->result->add($res);
  890. }
  891. }
  892. return $assoc ? $res : $this->result;
  893. }
  894. /**
  895. * Returns the last error occurred (e.g. when updating/inserting failed)
  896. *
  897. * @return array Hash array with the following fields: type, message
  898. */
  899. function get_error()
  900. {
  901. $err = $this->error;
  902. // check ldap connection for errors
  903. if (!$err && $this->ldap->get_error()) {
  904. $err = array(self::ERROR_SEARCH, $this->ldap->get_error());
  905. }
  906. return $err;
  907. }
  908. /**
  909. * Check the given data before saving.
  910. * If input not valid, the message to display can be fetched using get_error()
  911. *
  912. * @param array Assoziative array with data to save
  913. * @param boolean Try to fix/complete record automatically
  914. * @return boolean True if input is valid, False if not.
  915. */
  916. public function validate(&$save_data, $autofix = false)
  917. {
  918. // validate e-mail addresses
  919. if (!parent::validate($save_data, $autofix)) {
  920. return false;
  921. }
  922. // check for name input
  923. if (empty($save_data['name'])) {
  924. $this->set_error(self::ERROR_VALIDATE, 'nonamewarning');
  925. return false;
  926. }
  927. // Verify that the required fields are set.
  928. $missing = null;
  929. $ldap_data = $this->_map_data($save_data);
  930. foreach ($this->prop['required_fields'] as $fld) {
  931. if (!isset($ldap_data[$fld]) || $ldap_data[$fld] === '') {
  932. $missing[$fld] = 1;
  933. }
  934. }
  935. if ($missing) {
  936. // try to complete record automatically
  937. if ($autofix) {
  938. $sn_field = $this->fieldmap['surname'];
  939. $fn_field = $this->fieldmap['firstname'];
  940. $mail_field = $this->fieldmap['email'];
  941. // try to extract surname and firstname from displayname
  942. $name_parts = preg_split('/[\s,.]+/', $save_data['name']);
  943. if ($sn_field && $missing[$sn_field]) {
  944. $save_data['surname'] = array_pop($name_parts);
  945. unset($missing[$sn_field]);
  946. }
  947. if ($fn_field && $missing[$fn_field]) {
  948. $save_data['firstname'] = array_shift($name_parts);
  949. unset($missing[$fn_field]);
  950. }
  951. // try to fix missing e-mail, very often on import
  952. // from vCard we have email:other only defined
  953. if ($mail_field && $missing[$mail_field]) {
  954. $emails = $this->get_col_values('email', $save_data, true);
  955. if (!empty($emails) && ($email = array_shift($emails))) {
  956. $save_data['email'] = $email;
  957. unset($missing[$mail_field]);
  958. }
  959. }
  960. }
  961. // TODO: generate message saying which fields are missing
  962. if (!empty($missing)) {
  963. $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
  964. return false;
  965. }
  966. }
  967. return true;
  968. }
  969. /**
  970. * Create a new contact record
  971. *
  972. * @param array Associative array with save data
  973. * Keys: Field name with optional section in the form FIELD:SECTION
  974. * Values: Field value. Can be either a string or an array of strings for multiple values
  975. * @param boolean True to check for duplicates first
  976. *
  977. * @return mixed The created record ID on success, False on error
  978. */
  979. function insert($save_cols, $check = false)
  980. {
  981. // Map out the column names to their LDAP ones to build the new entry.
  982. $newentry = $this->_map_data($save_cols);
  983. $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
  984. // add automatically generated attributes
  985. $this->add_autovalues($newentry);
  986. // Verify that the required fields are set.
  987. $missing = null;
  988. foreach ($this->prop['required_fields'] as $fld) {
  989. if (!isset($newentry[$fld])) {
  990. $missing[] = $fld;
  991. }
  992. }
  993. // abort process if requiered fields are missing
  994. // TODO: generate message saying which fields are missing
  995. if ($missing) {
  996. $this->set_error(self::ERROR_VALIDATE, 'formincomplete');
  997. return false;
  998. }
  999. // Build the new entries DN.
  1000. $dn = $this->prop['LDAP_rdn'].'='.rcube_ldap_generic::quote_string($newentry[$this->prop['LDAP_rdn']], true).','.$this->base_dn;
  1001. // Remove attributes that need to be added separately (child objects)
  1002. $xfields = array();
  1003. if (!empty($this->prop['sub_fields']) && is_array($this->prop['sub_fields'])) {
  1004. foreach (array_keys($this->prop['sub_fields']) as $xf) {
  1005. if (!empty($newentry[$xf])) {
  1006. $xfields[$xf] = $newentry[$xf];
  1007. unset($newentry[$xf]);
  1008. }
  1009. }
  1010. }
  1011. if (!$this->ldap->add_entry($dn, $newentry)) {
  1012. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1013. return false;
  1014. }
  1015. foreach ($xfields as $xidx => $xf) {
  1016. $xdn = $xidx.'='.rcube_ldap_generic::quote_string($xf).','.$dn;
  1017. $xf = array(
  1018. $xidx => $xf,
  1019. 'objectClass' => (array) $this->prop['sub_fields'][$xidx],
  1020. );
  1021. $this->ldap->add_entry($xdn, $xf);
  1022. }
  1023. $dn = self::dn_encode($dn);
  1024. // add new contact to the selected group
  1025. if ($this->group_id)
  1026. $this->add_to_group($this->group_id, $dn);
  1027. return $dn;
  1028. }
  1029. /**
  1030. * Update a specific contact record
  1031. *
  1032. * @param mixed Record identifier
  1033. * @param array Hash array with save data
  1034. *
  1035. * @return boolean True on success, False on error
  1036. */
  1037. function update($id, $save_cols)
  1038. {
  1039. $record = $this->get_record($id, true);
  1040. $newdata = array();
  1041. $replacedata = array();
  1042. $deletedata = array();
  1043. $subdata = array();
  1044. $subdeldata = array();
  1045. $subnewdata = array();
  1046. $ldap_data = $this->_map_data($save_cols);
  1047. $old_data = $record['_raw_attrib'];
  1048. // special handling of photo col
  1049. if ($photo_fld = $this->fieldmap['photo']) {
  1050. // undefined means keep old photo
  1051. if (!array_key_exists('photo', $save_cols)) {
  1052. $ldap_data[$photo_fld] = $record['photo'];
  1053. }
  1054. }
  1055. foreach ($this->fieldmap as $fld) {
  1056. if ($fld) {
  1057. $val = $ldap_data[$fld];
  1058. $old = $old_data[$fld];
  1059. // remove empty array values
  1060. if (is_array($val))
  1061. $val = array_filter($val);
  1062. // $this->_map_data() result and _raw_attrib use different format
  1063. // make sure comparing array with one element with a string works as expected
  1064. if (is_array($old) && count($old) == 1 && !is_array($val)) {
  1065. $old = array_pop($old);
  1066. }
  1067. if (is_array($val) && count($val) == 1 && !is_array($old)) {
  1068. $val = array_pop($val);
  1069. }
  1070. // Subentries must be handled separately
  1071. if (!empty($this->prop['sub_fields']) && isset($this->prop['sub_fields'][$fld])) {
  1072. if ($old != $val) {
  1073. if ($old !== null) {
  1074. $subdeldata[$fld] = $old;
  1075. }
  1076. if ($val) {
  1077. $subnewdata[$fld] = $val;
  1078. }
  1079. }
  1080. else if ($old !== null) {
  1081. $subdata[$fld] = $old;
  1082. }
  1083. continue;
  1084. }
  1085. // The field does exist compare it to the ldap record.
  1086. if ($old != $val) {
  1087. // Changed, but find out how.
  1088. if ($old === null) {
  1089. // Field was not set prior, need to add it.
  1090. $newdata[$fld] = $val;
  1091. }
  1092. else if ($val == '') {
  1093. // Field supplied is empty, verify that it is not required.
  1094. if (!in_array($fld, $this->prop['required_fields'])) {
  1095. // ...It is not, safe to clear.
  1096. // #1488420: Workaround "ldap_mod_del(): Modify: Inappropriate matching in..."
  1097. // jpegPhoto attribute require an array() here. It looks to me that it works for other attribs too
  1098. $deletedata[$fld] = array();
  1099. //$deletedata[$fld] = $old_data[$fld];
  1100. }
  1101. }
  1102. else {
  1103. // The data was modified, save it out.
  1104. $replacedata[$fld] = $val;
  1105. }
  1106. } // end if
  1107. } // end if
  1108. } // end foreach
  1109. // console($old_data, $ldap_data, '----', $newdata, $replacedata, $deletedata, '----', $subdata, $subnewdata, $subdeldata);
  1110. $dn = self::dn_decode($id);
  1111. // Update the entry as required.
  1112. if (!empty($deletedata)) {
  1113. // Delete the fields.
  1114. if (!$this->ldap->mod_del($dn, $deletedata)) {
  1115. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1116. return false;
  1117. }
  1118. } // end if
  1119. if (!empty($replacedata)) {
  1120. // Handle RDN change
  1121. if ($replacedata[$this->prop['LDAP_rdn']]) {
  1122. $newdn = $this->prop['LDAP_rdn'].'='
  1123. .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
  1124. .','.$this->base_dn;
  1125. if ($dn != $newdn) {
  1126. $newrdn = $this->prop['LDAP_rdn'].'='
  1127. .rcube_ldap_generic::quote_string($replacedata[$this->prop['LDAP_rdn']], true);
  1128. unset($replacedata[$this->prop['LDAP_rdn']]);
  1129. }
  1130. }
  1131. // Replace the fields.
  1132. if (!empty($replacedata)) {
  1133. if (!$this->ldap->mod_replace($dn, $replacedata)) {
  1134. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1135. return false;
  1136. }
  1137. }
  1138. } // end if
  1139. // RDN change, we need to remove all sub-entries
  1140. if (!empty($newrdn)) {
  1141. $subdeldata = array_merge($subdeldata, $subdata);
  1142. $subnewdata = array_merge($subnewdata, $subdata);
  1143. }
  1144. // remove sub-entries
  1145. if (!empty($subdeldata)) {
  1146. foreach ($subdeldata as $fld => $val) {
  1147. $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
  1148. if (!$this->ldap->delete_entry($subdn)) {
  1149. return false;
  1150. }
  1151. }
  1152. }
  1153. if (!empty($newdata)) {
  1154. // Add the fields.
  1155. if (!$this->ldap->mod_add($dn, $newdata)) {
  1156. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1157. return false;
  1158. }
  1159. } // end if
  1160. // Handle RDN change
  1161. if (!empty($newrdn)) {
  1162. if (!$this->ldap->rename($dn, $newrdn, null, true)) {
  1163. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1164. return false;
  1165. }
  1166. $dn = self::dn_encode($dn);
  1167. $newdn = self::dn_encode($newdn);
  1168. // change the group membership of the contact
  1169. if ($this->groups) {
  1170. $group_ids = $this->get_record_groups($dn);
  1171. foreach (array_keys($group_ids) as $group_id) {
  1172. $this->remove_from_group($group_id, $dn);
  1173. $this->add_to_group($group_id, $newdn);
  1174. }
  1175. }
  1176. $dn = self::dn_decode($newdn);
  1177. }
  1178. // add sub-entries
  1179. if (!empty($subnewdata)) {
  1180. foreach ($subnewdata as $fld => $val) {
  1181. $subdn = $fld.'='.rcube_ldap_generic::quote_string($val).','.$dn;
  1182. $xf = array(
  1183. $fld => $val,
  1184. 'objectClass' => (array) $this->prop['sub_fields'][$fld],
  1185. );
  1186. $this->ldap->add_entry($subdn, $xf);
  1187. }
  1188. }
  1189. return $newdn ?: true;
  1190. }
  1191. /**
  1192. * Mark one or more contact records as deleted
  1193. *
  1194. * @param array Record identifiers
  1195. * @param boolean Remove record(s) irreversible (unsupported)
  1196. *
  1197. * @return boolean True on success, False on error
  1198. */
  1199. function delete($ids, $force=true)
  1200. {
  1201. if (!is_array($ids)) {
  1202. // Not an array, break apart the encoded DNs.
  1203. $ids = explode(',', $ids);
  1204. } // end if
  1205. foreach ($ids as $id) {
  1206. $dn = self::dn_decode($id);
  1207. // Need to delete all sub-entries first
  1208. if ($this->sub_filter) {
  1209. if ($entries = $this->ldap->list_entries($dn, $this->sub_filter)) {
  1210. foreach ($entries as $entry) {
  1211. if (!$this->ldap->delete_entry($entry['dn'])) {
  1212. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1213. return false;
  1214. }
  1215. }
  1216. }
  1217. }
  1218. // Delete the record.
  1219. if (!$this->ldap->delete_entry($dn)) {
  1220. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1221. return false;
  1222. }
  1223. // remove contact from all groups where he was a member
  1224. if ($this->groups) {
  1225. $dn = self::dn_encode($dn);
  1226. $group_ids = $this->get_record_groups($dn);
  1227. foreach (array_keys($group_ids) as $group_id) {
  1228. $this->remove_from_group($group_id, $dn);
  1229. }
  1230. }
  1231. } // end foreach
  1232. return count($ids);
  1233. }
  1234. /**
  1235. * Remove all contact records
  1236. *
  1237. * @param bool $with_groups Delete also groups if enabled
  1238. */
  1239. function delete_all($with_groups = false)
  1240. {
  1241. // searching for contact entries
  1242. $dn_list = $this->ldap->list_entries($this->base_dn, $this->prop['filter'] ?: '(objectclass=*)');
  1243. if (!empty($dn_list)) {
  1244. foreach ($dn_list as $idx => $entry) {
  1245. $dn_list[$idx] = self::dn_encode($entry['dn']);
  1246. }
  1247. $this->delete($dn_list);
  1248. }
  1249. if ($with_groups && $this->groups && ($groups = $this->_fetch_groups()) && count($groups)) {
  1250. foreach ($groups as $group) {
  1251. $this->ldap->delete_entry($group['dn']);
  1252. }
  1253. if ($this->cache) {
  1254. $this->cache->remove('groups');
  1255. }
  1256. }
  1257. }
  1258. /**
  1259. * Generate missing attributes as configured
  1260. *
  1261. * @param array LDAP record attributes
  1262. */
  1263. protected function add_autovalues(&$attrs)
  1264. {
  1265. if (empty($this->prop['autovalues'])) {
  1266. return;
  1267. }
  1268. $attrvals = array();
  1269. foreach ($attrs as $k => $v) {
  1270. $attrvals['{'.$k.'}'] = is_array($v) ? $v[0] : $v;
  1271. }
  1272. foreach ((array)$this->prop['autovalues'] as $lf => $templ) {
  1273. if (empty($attrs[$lf])) {
  1274. if (strpos($templ, '(') !== false) {
  1275. // replace {attr} placeholders with (escaped!) attribute values to be safely eval'd
  1276. $code = preg_replace('/\{\w+\}/', '', strtr($templ, array_map('addslashes', $attrvals)));
  1277. $res = false;
  1278. try {
  1279. $res = eval("return ($code);");
  1280. }
  1281. catch (ParseError $e) {
  1282. // ignore
  1283. }
  1284. if ($res === false) {
  1285. rcube::raise_error(array(
  1286. 'code' => 505, 'file' => __FILE__, 'line' => __LINE__,
  1287. 'message' => "Expression parse error on: ($code)"), true, false);
  1288. continue;
  1289. }
  1290. $attrs[$lf] = $res;
  1291. }
  1292. else {
  1293. // replace {attr} placeholders with concrete attribute values
  1294. $attrs[$lf] = preg_replace('/\{\w+\}/', '', strtr($templ, $attrvals));
  1295. }
  1296. }
  1297. }
  1298. }
  1299. /**
  1300. * Converts LDAP entry into an array
  1301. */
  1302. private function _ldap2result($rec)
  1303. {
  1304. $out = array('_type' => 'person');
  1305. $fieldmap = $this->fieldmap;
  1306. if ($rec['dn'])
  1307. $out[$this->primary_key] = self::dn_encode($rec['dn']);
  1308. // determine record type
  1309. if ($this->is_group_entry($rec)) {
  1310. $out['_type'] = 'group';
  1311. $out['readonly'] = true;
  1312. $fieldmap['name'] = $this->group_data['name_attr'] ?: $this->prop['groups']['name_attr'];
  1313. }
  1314. // assign object type from object class mapping
  1315. if (!empty($this->prop['class_type_map'])) {
  1316. foreach (array_map('strtolower', (array)$rec['objectclass']) as $objcls) {
  1317. if (!empty($this->prop['class_type_map'][$objcls])) {
  1318. $out['_type'] = $this->prop['class_type_map'][$objcls];
  1319. break;
  1320. }
  1321. }
  1322. }
  1323. foreach ($fieldmap as $rf => $lf)
  1324. {
  1325. // we might be dealing with normalized and non-normalized data
  1326. $entry = $rec[$lf];
  1327. if (!is_array($entry) || !isset($entry['count'])) {
  1328. $entry = (array) $entry;
  1329. $entry['count'] = count($entry);
  1330. }
  1331. for ($i=0; $i < $entry['count']; $i++) {
  1332. if (!($value = $entry[$i]))
  1333. continue;
  1334. list($col, $subtype) = explode(':', $rf);
  1335. $out['_raw_attrib'][$lf][$i] = $value;
  1336. if ($col == 'email' && $this->mail_domain && !strpos($value, '@'))
  1337. $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
  1338. else if (in_array($col, array('street','zipcode','locality','country','region')))
  1339. $out['address' . ($subtype ? ':' : '') . $subtype][$i][$col] = $value;
  1340. else if ($col == 'address' && strpos($value, '$') !== false) // address data is represented as string separated with $
  1341. list($out[$rf][$i]['street'], $out[$rf][$i]['locality'], $out[$rf][$i]['zipcode'], $out[$rf][$i]['country']) = explode('$', $value);
  1342. else if ($entry['count'] > 1)
  1343. $out[$rf][] = $value;
  1344. else
  1345. $out[$rf] = $value;
  1346. }
  1347. // Make sure name fields aren't arrays (#1488108)
  1348. if (is_array($out[$rf]) && in_array($rf, array('name', 'surname', 'firstname', 'middlename', 'nickname'))) {
  1349. $out[$rf] = $out['_raw_attrib'][$lf] = $out[$rf][0];
  1350. }
  1351. }
  1352. return $out;
  1353. }
  1354. /**
  1355. * Return LDAP attribute(s) for the given field
  1356. */
  1357. private function _map_field($field)
  1358. {
  1359. return (array)$this->coltypes[$field]['attributes'];
  1360. }
  1361. /**
  1362. * Convert a record data set into LDAP field attributes
  1363. */
  1364. private function _map_data($save_cols)
  1365. {
  1366. // flatten composite fields first
  1367. foreach ($this->coltypes as $col => $colprop) {
  1368. if (is_array($colprop['childs']) && ($values = $this->get_col_values($col, $save_cols, false))) {
  1369. foreach ($values as $subtype => $childs) {
  1370. $subtype = $subtype ? ':'.$subtype : '';
  1371. foreach ($childs as $i => $child_values) {
  1372. foreach ((array)$child_values as $childcol => $value) {
  1373. $save_cols[$childcol.$subtype][$i] = $value;
  1374. }
  1375. }
  1376. }
  1377. }
  1378. // if addresses are to be saved as serialized string, do so
  1379. if (is_array($colprop['serialized'])) {
  1380. foreach ($colprop['serialized'] as $subtype => $delim) {
  1381. $key = $col.':'.$subtype;
  1382. foreach ((array)$save_cols[$key] as $i => $val) {
  1383. $values = array($val['street'], $val['locality'], $val['zipcode'], $val['country']);
  1384. $save_cols[$key][$i] = count(array_filter($values)) ? join($delim, $values) : null;
  1385. }
  1386. }
  1387. }
  1388. }
  1389. $ldap_data = array();
  1390. foreach ($this->fieldmap as $rf => $fld) {
  1391. $val = $save_cols[$rf];
  1392. // check for value in base field (eg.g email instead of email:foo)
  1393. list($col, $subtype) = explode(':', $rf);
  1394. if (!$val && !empty($save_cols[$col])) {
  1395. $val = $save_cols[$col];
  1396. unset($save_cols[$col]); // only use this value once
  1397. }
  1398. else if (!$val && !$subtype) { // extract values from subtype cols
  1399. $val = $this->get_col_values($col, $save_cols, true);
  1400. }
  1401. if (is_array($val))
  1402. $val = array_filter($val); // remove empty entries
  1403. if ($fld && $val) {
  1404. // The field does exist, add it to the entry.
  1405. $ldap_data[$fld] = $val;
  1406. }
  1407. }
  1408. foreach ($this->formats as $fld => $format) {
  1409. if (empty($ldap_data[$fld])) {
  1410. continue;
  1411. }
  1412. switch ($format['type']) {
  1413. case 'date':
  1414. if ($dt = rcube_utils::anytodatetime($ldap_data[$fld])) {
  1415. $ldap_data[$fld] = $dt->format($format['format']);
  1416. }
  1417. break;
  1418. }
  1419. }
  1420. return $ldap_data;
  1421. }
  1422. /**
  1423. * Returns unified attribute name (resolving aliases)
  1424. */
  1425. private static function _attr_name($namev)
  1426. {
  1427. // list of known attribute aliases
  1428. static $aliases = array(
  1429. 'gn' => 'givenname',
  1430. 'rfc822mailbox' => 'email',
  1431. 'userid' => 'uid',
  1432. 'emailaddress' => 'email',
  1433. 'pkcs9email' => 'email',
  1434. );
  1435. list($name, $limit) = explode(':', $namev, 2);
  1436. $suffix = $limit ? ':'.$limit : '';
  1437. $name = strtolower($name);
  1438. return (isset($aliases[$name]) ? $aliases[$name] : $name) . $suffix;
  1439. }
  1440. /**
  1441. * Determines whether the given LDAP entry is a group record
  1442. */
  1443. private function is_group_entry($entry)
  1444. {
  1445. $classes = array_map('strtolower', (array)$entry['objectclass']);
  1446. return count(array_intersect(array_keys($this->group_types), $classes)) > 0;
  1447. }
  1448. /**
  1449. * Activate/deactivate debug mode
  1450. *
  1451. * @param boolean $dbg True if LDAP commands should be logged
  1452. */
  1453. function set_debug($dbg = true)
  1454. {
  1455. $this->debug = $dbg;
  1456. if ($this->ldap) {
  1457. $this->ldap->config_set('debug', $dbg);
  1458. }
  1459. }
  1460. /**
  1461. * Setter for the current group
  1462. */
  1463. function set_group($group_id)
  1464. {
  1465. if ($group_id) {
  1466. $this->group_id = $group_id;
  1467. $this->group_data = $this->get_group_entry($group_id);
  1468. }
  1469. else {
  1470. $this->group_id = 0;
  1471. $this->group_data = null;
  1472. }
  1473. }
  1474. /**
  1475. * List all active contact groups of this source
  1476. *
  1477. * @param string Optional search string to match group name
  1478. * @param int Matching mode. Sum of rcube_addressbook::SEARCH_*
  1479. *
  1480. * @return array Indexed list of contact groups, each a hash array
  1481. */
  1482. function list_groups($search = null, $mode = 0)
  1483. {
  1484. if (!$this->groups) {
  1485. return array();
  1486. }
  1487. $group_cache = $this->_fetch_groups($search, $mode);
  1488. $groups = array();
  1489. if ($search) {
  1490. foreach ($group_cache as $group) {
  1491. if ($this->compare_search_value('name', $group['name'], mb_strtolower($search), $mode)) {
  1492. $groups[] = $group;
  1493. }
  1494. }
  1495. }
  1496. else {
  1497. $groups = $group_cache;
  1498. }
  1499. return array_values($groups);
  1500. }
  1501. /**
  1502. * Fetch groups from server
  1503. */
  1504. private function _fetch_groups($search = null, $mode = 0, $vlv_page = null)
  1505. {
  1506. // reset group search cache
  1507. if ($search !== null && $vlv_page === null) {
  1508. $this->group_search_cache = null;
  1509. }
  1510. // return in-memory cache from previous search results
  1511. else if (is_array($this->group_search_cache) && $vlv_page === null) {
  1512. return $this->group_search_cache;
  1513. }
  1514. // special case: list groups from 'group_filters' config
  1515. if ($vlv_page === null && $search === null && is_array($this->prop['group_filters'])) {
  1516. $groups = array();
  1517. $rcube = rcube::get_instance();
  1518. // list regular groups configuration as special filter
  1519. if (!empty($this->prop['groups']['filter'])) {
  1520. $id = '__groups__';
  1521. $groups[$id] = array('ID' => $id, 'name' => $rcube->gettext('groups'), 'virtual' => true) + $this->prop['groups'];
  1522. }
  1523. foreach ($this->prop['group_filters'] as $id => $prop) {
  1524. $groups[$id] = $prop + array('ID' => $id, 'name' => ucfirst($id), 'virtual' => true, 'base_dn' => $this->base_dn);
  1525. }
  1526. return $groups;
  1527. }
  1528. if ($this->cache && $search === null && $vlv_page === null && ($groups = $this->cache->get('groups')) !== null) {
  1529. return $groups;
  1530. }
  1531. $base_dn = $this->groups_base_dn;
  1532. $filter = $this->prop['groups']['filter'];
  1533. $scope = $this->prop['groups']['scope'];
  1534. $name_attr = $this->prop['groups']['name_attr'];
  1535. $email_attr = $this->prop['groups']['email_attr'] ?: 'mail';
  1536. $sort_attrs = $this->prop['groups']['sort'] ? (array)$this->prop['groups']['sort'] : array($name_attr);
  1537. $sort_attr = $sort_attrs[0];
  1538. $ldap = $this->ldap;
  1539. // use vlv to list groups
  1540. if ($this->prop['groups']['vlv']) {
  1541. $page_size = 200;
  1542. if (!$this->prop['groups']['sort']) {
  1543. $this->prop['groups']['sort'] = $sort_attrs;
  1544. }
  1545. $ldap = clone $this->ldap;
  1546. $ldap->config_set($this->prop['groups']);
  1547. $ldap->set_vlv_page($vlv_page+1, $page_size);
  1548. }
  1549. $props = array('sort' => $this->prop['groups']['sort']);
  1550. $attrs = array_unique(array('dn', 'objectClass', $name_attr, $email_attr, $sort_attr));
  1551. // add search filter
  1552. if ($search !== null) {
  1553. // set wildcards
  1554. $wp = $ws = '';
  1555. if (!empty($this->prop['fuzzy_search']) && !($mode & rcube_addressbook::SEARCH_STRICT)) {
  1556. $ws = '*';
  1557. if (!($mode & rcube_addressbook::SEARCH_PREFIX)) {
  1558. $wp = '*';
  1559. }
  1560. }
  1561. $filter = "(&$filter($name_attr=$wp" . rcube_ldap_generic::quote_string($search) . "$ws))";
  1562. $props['search'] = $wp . $search . $ws;
  1563. }
  1564. $ldap_data = $ldap->search($base_dn, $filter, $scope, $attrs, $props);
  1565. if ($ldap_data === false) {
  1566. return array();
  1567. }
  1568. $groups = array();
  1569. $group_sortnames = array();
  1570. $group_count = $ldap_data->count();
  1571. foreach ($ldap_data as $entry) {
  1572. if (!$entry['dn']) // DN is mandatory
  1573. $entry['dn'] = $ldap_data->get_dn();
  1574. $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
  1575. $group_id = self::dn_encode($entry['dn']);
  1576. $groups[$group_id]['ID'] = $group_id;
  1577. $groups[$group_id]['dn'] = $entry['dn'];
  1578. $groups[$group_id]['name'] = $group_name;
  1579. $groups[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
  1580. // list email attributes of a group
  1581. for ($j=0; $entry[$email_attr] && $j < $entry[$email_attr]['count']; $j++) {
  1582. if (strpos($entry[$email_attr][$j], '@') > 0)
  1583. $groups[$group_id]['email'][] = $entry[$email_attr][$j];
  1584. }
  1585. $group_sortnames[] = mb_strtolower($entry[$sort_attr][0]);
  1586. }
  1587. // recursive call can exit here
  1588. if ($vlv_page > 0) {
  1589. return $groups;
  1590. }
  1591. // call recursively until we have fetched all groups
  1592. while ($this->prop['groups']['vlv'] && $group_count == $page_size) {
  1593. $next_page = $this->_fetch_groups($search, $mode, ++$vlv_page);
  1594. $groups = array_merge($groups, $next_page);
  1595. $group_count = count($next_page);
  1596. }
  1597. // when using VLV the list of groups is already sorted
  1598. if (!$this->prop['groups']['vlv']) {
  1599. array_multisort($group_sortnames, SORT_ASC, SORT_STRING, $groups);
  1600. }
  1601. // cache this
  1602. if ($this->cache && $search === null) {
  1603. $this->cache->set('groups', $groups);
  1604. }
  1605. else if ($search !== null) {
  1606. $this->group_search_cache = $groups;
  1607. }
  1608. return $groups;
  1609. }
  1610. /**
  1611. * Fetch a group entry from LDAP and save in local cache
  1612. */
  1613. private function get_group_entry($group_id)
  1614. {
  1615. $group_cache = $this->_fetch_groups();
  1616. // add group record to cache if it isn't yet there
  1617. if (!isset($group_cache[$group_id])) {
  1618. $name_attr = $this->prop['groups']['name_attr'];
  1619. $dn = self::dn_decode($group_id);
  1620. if ($list = $this->ldap->read_entries($dn, '(objectClass=*)', array('dn','objectClass','member','uniqueMember','memberURL',$name_attr,$this->fieldmap['email']))) {
  1621. $entry = $list[0];
  1622. $group_name = is_array($entry[$name_attr]) ? $entry[$name_attr][0] : $entry[$name_attr];
  1623. $group_cache[$group_id]['ID'] = $group_id;
  1624. $group_cache[$group_id]['dn'] = $dn;
  1625. $group_cache[$group_id]['name'] = $group_name;
  1626. $group_cache[$group_id]['member_attr'] = $this->get_group_member_attr($entry['objectclass']);
  1627. }
  1628. else {
  1629. $group_cache[$group_id] = false;
  1630. }
  1631. if ($this->cache) {
  1632. $this->cache->set('groups', $group_cache);
  1633. }
  1634. }
  1635. return $group_cache[$group_id];
  1636. }
  1637. /**
  1638. * Get group properties such as name and email address(es)
  1639. *
  1640. * @param string Group identifier
  1641. * @return array Group properties as hash array
  1642. */
  1643. function get_group($group_id)
  1644. {
  1645. $group_data = $this->get_group_entry($group_id);
  1646. unset($group_data['dn'], $group_data['member_attr']);
  1647. return $group_data;
  1648. }
  1649. /**
  1650. * Create a contact group with the given name
  1651. *
  1652. * @param string The group name
  1653. * @return mixed False on error, array with record props in success
  1654. */
  1655. function create_group($group_name)
  1656. {
  1657. $new_dn = 'cn=' . rcube_ldap_generic::quote_string($group_name, true) . ',' . $this->groups_base_dn;
  1658. $new_gid = self::dn_encode($new_dn);
  1659. $member_attr = $this->get_group_member_attr();
  1660. $name_attr = $this->prop['groups']['name_attr'] ?: 'cn';
  1661. $new_entry = array(
  1662. 'objectClass' => $this->prop['groups']['object_classes'],
  1663. $name_attr => $group_name,
  1664. $member_attr => '',
  1665. );
  1666. if (!$this->ldap->add_entry($new_dn, $new_entry)) {
  1667. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1668. return false;
  1669. }
  1670. if ($this->cache) {
  1671. $this->cache->remove('groups');
  1672. }
  1673. return array('id' => $new_gid, 'name' => $group_name);
  1674. }
  1675. /**
  1676. * Delete the given group and all linked group members
  1677. *
  1678. * @param string Group identifier
  1679. * @return boolean True on success, false if no data was changed
  1680. */
  1681. function delete_group($group_id)
  1682. {
  1683. $group_cache = $this->_fetch_groups();
  1684. $del_dn = $group_cache[$group_id]['dn'];
  1685. if (!$this->ldap->delete_entry($del_dn)) {
  1686. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1687. return false;
  1688. }
  1689. if ($this->cache) {
  1690. unset($group_cache[$group_id]);
  1691. $this->cache->set('groups', $group_cache);
  1692. }
  1693. return true;
  1694. }
  1695. /**
  1696. * Rename a specific contact group
  1697. *
  1698. * @param string Group identifier
  1699. * @param string New name to set for this group
  1700. * @param string New group identifier (if changed, otherwise don't set)
  1701. * @return boolean New name on success, false if no data was changed
  1702. */
  1703. function rename_group($group_id, $new_name, &$new_gid)
  1704. {
  1705. $group_cache = $this->_fetch_groups();
  1706. $old_dn = $group_cache[$group_id]['dn'];
  1707. $new_rdn = "cn=" . rcube_ldap_generic::quote_string($new_name, true);
  1708. $new_gid = self::dn_encode($new_rdn . ',' . $this->groups_base_dn);
  1709. if (!$this->ldap->rename($old_dn, $new_rdn, null, true)) {
  1710. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1711. return false;
  1712. }
  1713. if ($this->cache) {
  1714. $this->cache->remove('groups');
  1715. }
  1716. return $new_name;
  1717. }
  1718. /**
  1719. * Add the given contact records the a certain group
  1720. *
  1721. * @param string Group identifier
  1722. * @param array|string List of contact identifiers to be added
  1723. *
  1724. * @return int Number of contacts added
  1725. */
  1726. function add_to_group($group_id, $contact_ids)
  1727. {
  1728. $group_cache = $this->_fetch_groups();
  1729. $member_attr = $group_cache[$group_id]['member_attr'];
  1730. $group_dn = $group_cache[$group_id]['dn'];
  1731. $new_attrs = array();
  1732. if (!is_array($contact_ids)) {
  1733. $contact_ids = explode(',', $contact_ids);
  1734. }
  1735. foreach ($contact_ids as $id) {
  1736. $new_attrs[$member_attr][] = self::dn_decode($id);
  1737. }
  1738. if (!$this->ldap->mod_add($group_dn, $new_attrs)) {
  1739. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1740. return 0;
  1741. }
  1742. if ($this->cache) {
  1743. $this->cache->remove('groups');
  1744. }
  1745. return count($new_attrs[$member_attr]);
  1746. }
  1747. /**
  1748. * Remove the given contact records from a certain group
  1749. *
  1750. * @param string Group identifier
  1751. * @param array|string List of contact identifiers to be removed
  1752. *
  1753. * @return int Number of deleted group members
  1754. */
  1755. function remove_from_group($group_id, $contact_ids)
  1756. {
  1757. $group_cache = $this->_fetch_groups();
  1758. $member_attr = $group_cache[$group_id]['member_attr'];
  1759. $group_dn = $group_cache[$group_id]['dn'];
  1760. $del_attrs = array();
  1761. if (!is_array($contact_ids)) {
  1762. $contact_ids = explode(',', $contact_ids);
  1763. }
  1764. foreach ($contact_ids as $id) {
  1765. $del_attrs[$member_attr][] = self::dn_decode($id);
  1766. }
  1767. if (!$this->ldap->mod_del($group_dn, $del_attrs)) {
  1768. $this->set_error(self::ERROR_SAVING, 'errorsaving');
  1769. return 0;
  1770. }
  1771. if ($this->cache) {
  1772. $this->cache->remove('groups');
  1773. }
  1774. return count($del_attrs[$member_attr]);
  1775. }
  1776. /**
  1777. * Get group assignments of a specific contact record
  1778. *
  1779. * @param mixed Record identifier
  1780. *
  1781. * @return array List of assigned groups as ID=>Name pairs
  1782. * @since 0.5-beta
  1783. */
  1784. function get_record_groups($contact_id)
  1785. {
  1786. if (!$this->groups) {
  1787. return array();
  1788. }
  1789. $base_dn = $this->groups_base_dn;
  1790. $contact_dn = self::dn_decode($contact_id);
  1791. $name_attr = $this->prop['groups']['name_attr'] ?: 'cn';
  1792. $member_attr = $this->get_group_member_attr();
  1793. $add_filter = '';
  1794. if ($member_attr != 'member' && $member_attr != 'uniqueMember')
  1795. $add_filter = "($member_attr=$contact_dn)";
  1796. $filter = strtr("(|(member=$contact_dn)(uniqueMember=$contact_dn)$add_filter)", array('\\' => '\\\\'));
  1797. $ldap_data = $this->ldap->search($base_dn, $filter, 'sub', array('dn', $name_attr));
  1798. if ($ldap_data === false) {
  1799. return array();
  1800. }
  1801. $groups = array();
  1802. foreach ($ldap_data as $entry) {
  1803. if (!$entry['dn'])
  1804. $entry['dn'] = $ldap_data->get_dn();
  1805. $group_name = $entry[$name_attr][0];
  1806. $group_id = self::dn_encode($entry['dn']);
  1807. $groups[$group_id] = $group_name;
  1808. }
  1809. return $groups;
  1810. }
  1811. /**
  1812. * Detects group member attribute name
  1813. */
  1814. private function get_group_member_attr($object_classes = array(), $default = 'member')
  1815. {
  1816. if (empty($object_classes)) {
  1817. $object_classes = $this->prop['groups']['object_classes'];
  1818. }
  1819. if (!empty($object_classes)) {
  1820. foreach ((array)$object_classes as $oc) {
  1821. if ($attr = $this->group_types[strtolower($oc)]) {
  1822. return $attr;
  1823. }
  1824. }
  1825. }
  1826. if (!empty($this->prop['groups']['member_attr'])) {
  1827. return $this->prop['groups']['member_attr'];
  1828. }
  1829. return $default;
  1830. }
  1831. /**
  1832. * HTML-safe DN string encoding
  1833. *
  1834. * @param string $str DN string
  1835. *
  1836. * @return string Encoded HTML identifier string
  1837. */
  1838. static function dn_encode($str)
  1839. {
  1840. // @TODO: to make output string shorter we could probably
  1841. // remove dc=* items from it
  1842. return rtrim(strtr(base64_encode($str), '+/', '-_'), '=');
  1843. }
  1844. /**
  1845. * Decodes DN string encoded with _dn_encode()
  1846. *
  1847. * @param string $str Encoded HTML identifier string
  1848. *
  1849. * @return string DN string
  1850. */
  1851. static function dn_decode($str)
  1852. {
  1853. $str = str_pad(strtr($str, '-_', '+/'), strlen($str) % 4, '=', STR_PAD_RIGHT);
  1854. return base64_decode($str);
  1855. }
  1856. }