├── .gitignore ├── config.inc.php.dist ├── composer.json ├── README.rst ├── nextcloud_sql_addressbook.php └── nextcloud_sql_addressbook_backend.php /.gitignore: -------------------------------------------------------------------------------- 1 | /config.inc.php 2 | /README.html 3 | -------------------------------------------------------------------------------- /config.inc.php.dist: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cweiske/nextcloud_sql_addressbook", 3 | "description": "Roundcube plugin that allows access to NextCloud address books.", 4 | "type": "roundcube-plugin", 5 | "license": "AGPL-3.0+", 6 | "repositories": [ 7 | { 8 | "type": "composer", 9 | "url": "https://plugins.roundcube.net" 10 | } 11 | ], 12 | "require": { 13 | "roundcube/plugin-installer": ">=0.1.6" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | *********************************************** 2 | NextCloud SQL address book plugin for Roundcube 3 | *********************************************** 4 | 5 | Roundcube__ plugin that allows access to NextCloud__ address books. 6 | Uses direct database access (SQL), which is much faster than accessing the 7 | address book entries via the `CardDAV plugin`__. 8 | 9 | __ https://roundcube.net/ 10 | __ https://nextcloud.com/ 11 | __ https://plugins.roundcube.net/packages/roundcube/carddav 12 | 13 | 14 | Features 15 | ======== 16 | - List all user's NextCloud address books 17 | - Search 18 | - Autocomplete 19 | 20 | Missing features 21 | ---------------- 22 | - Access to address books shared by other people in NextCloud 23 | - Access to more fields than "email" and "name" (full name) 24 | - Updating/adding address book entries (not planned) 25 | 26 | 27 | Prerequisites 28 | ============= 29 | - Read-only SQL database access from Roundcube to the NextCloud database. 30 | Read access to the following tables needed: 31 | 32 | - ``oc_addressbooks`` 33 | - ``oc_cards_properties`` 34 | - Roundcube user login e-mail addresses must equal the username in NextCloud 35 | (Users log in with `alice@example.org` in both Roundcube and NextCloud) 36 | - Known to work with: 37 | - PHP 7.3.0 38 | - NextCloud 14 39 | - Roundcube 1.4.0 40 | 41 | 42 | Installation 43 | ============ 44 | #. Clone the git repository into the roundcube ``plugins/`` directory as 45 | ``nextcloud_sql_addressbook``. 46 | #. Copy ``config.inc.php.dist`` to ``config.inc.php`` and adjust it: 47 | 48 | #. Database connection 49 | #. Table prefix (defaults to ``oc_``) 50 | #. Enable the plugin in roundcube's ``config/config.inc.php`` file by 51 | adding it to the ``$config['plugins']`` array. 52 | 53 | 54 | Debugging 55 | ========= 56 | If you do not see any address books: 57 | The address books are only found if the ``principaluri`` in the ``oc_addressbooks`` 58 | table equals ``principals/users/`` + ``$useremailaddress``. 59 | 60 | If you do not see all contacts: Only contacts with an e-mail address are shown. 61 | 62 | 63 | Links 64 | ===== 65 | - Git repository: https://git.cweiske.de/roundcube-nextcloud_sql_addressbook.git 66 | - Git mirror: https://github.com/cweiske/roundcube-nextcloud_sql_addressbook 67 | - Roundcube plugin page: https://plugins.roundcube.net/packages/cweiske/nextcloud_sql_addressbook 68 | -------------------------------------------------------------------------------- /nextcloud_sql_addressbook.php: -------------------------------------------------------------------------------- 1 | 10 | * @license AGPLv3+ http://www.gnu.org/licenses/agpl.html 11 | */ 12 | class nextcloud_sql_addressbook extends rcube_plugin 13 | { 14 | /** 15 | * Main roundcube instance 16 | * 17 | * @var rcube 18 | */ 19 | protected $rcube; 20 | 21 | /** 22 | * Database table prefix 23 | * 24 | * @var string 25 | */ 26 | protected $prefix = 'oc_'; 27 | 28 | /** 29 | * Database instance 30 | */ 31 | protected $db; 32 | 33 | /** 34 | * Initialization method, needs to be implemented by the plugin itself 35 | * 36 | * @return void 37 | */ 38 | public function init() 39 | { 40 | $this->load_config(); 41 | $this->add_hook('addressbooks_list', [$this, 'addressbooks_list']); 42 | $this->add_hook('addressbook_get', [$this, 'addressbook_get']); 43 | 44 | $this->rcube = rcube::get_instance(); 45 | 46 | $this->db = rcube_db::factory( 47 | $this->rcube->config->get('nextcloud_sql_addressbook_dsn') 48 | ); 49 | $this->db->set_debug((bool) $this->rcube->config->get('sql_debug')); 50 | 51 | $this->prefix = $this->rcube->config->get( 52 | 'nextcloud_sql_addressbook_dbtableprefix', 'oc_' 53 | ); 54 | 55 | // use this address books for autocompletion queries 56 | $config = rcmail::get_instance()->config; 57 | $sources = (array) $config->get( 58 | 'autocomplete_addressbooks', array('sql') 59 | ); 60 | foreach ($this->listAddressbooks() as $addressBook) { 61 | if (!in_array($addressBook['id'], $sources)) { 62 | $sources[] = $addressBook['id']; 63 | } 64 | } 65 | $config->set('autocomplete_addressbooks', $sources); 66 | } 67 | 68 | /** 69 | * Load the nextcloud address book names 70 | * 71 | * The "id" may not contain any "-" because that would break "_cid", 72 | * the "contact IDs" which are "$contactid-$addressbookid". 73 | * See rcmail_get_cids() 74 | * 75 | * @param array $arguments Unknown data, with a "sources" key that we have 76 | * to modify 77 | * 78 | * @return array Arguments with our address books added to the "sources" key 79 | */ 80 | public function addressbooks_list($arguments) 81 | { 82 | $arguments['sources'] = array_merge( 83 | $arguments['sources'], $this->listAddressbooks() 84 | ); 85 | return $arguments; 86 | } 87 | 88 | /** 89 | * Build a list of address books for the user 90 | * 91 | * @return array Array of arrays with the following keys: 92 | * id, name, groups, readonly, undelete, autocomplete 93 | */ 94 | protected function listAddressbooks() 95 | { 96 | if (!isset($this->rcube->user->data)) { 97 | return []; 98 | } 99 | 100 | $principalUri = 'principals/users/' 101 | . $this->rcube->user->data['username']; 102 | 103 | $sql = 'SELECT id, displayname' 104 | . ' FROM ' . $this->prefix . 'addressbooks' 105 | . ' WHERE principaluri = ?' 106 | . ' ORDER BY displayname'; 107 | $stmt = $this->db->query($sql, [$principalUri]); 108 | $addressBooks = []; 109 | foreach ($stmt as $row) { 110 | $addressBooks[] = [ 111 | 'id' => 'nextcloud_' . $row['id'], 112 | 'name' => $row['displayname'] . ' (Nextcloud)', 113 | 'groups' => false, 114 | 'readonly' => true, 115 | 'undelete' => false, 116 | 'autocomplete' => true, 117 | ]; 118 | } 119 | return $addressBooks; 120 | } 121 | 122 | /** 123 | * Return a adress book object for the given address book ID 124 | * 125 | * @param array $arguments Some data with an "id" key that contains the 126 | * address book ID 127 | * 128 | * @return array $arguments with added "instance" key 129 | */ 130 | public function addressbook_get($arguments) 131 | { 132 | if (!isset($arguments['id'])) { 133 | return $arguments; 134 | } 135 | $parts = explode('_', $arguments['id'], 2); 136 | if (count($parts) != 2 || $parts[0] != 'nextcloud') { 137 | return $arguments; 138 | } 139 | 140 | $id = $parts[1]; 141 | //FIXME: security check if this ID really belongs to the user 142 | 143 | $arguments['instance'] = new nextcloud_sql_addressbook_backend( 144 | $id, $this->db, $this->prefix 145 | ); 146 | 147 | return $arguments; 148 | } 149 | } 150 | ?> 151 | -------------------------------------------------------------------------------- /nextcloud_sql_addressbook_backend.php: -------------------------------------------------------------------------------- 1 | 10 | * @license AGPLv3+ http://www.gnu.org/licenses/agpl.html 11 | */ 12 | class nextcloud_sql_addressbook_backend extends rcube_addressbook 13 | { 14 | /** 15 | * Nextcloud address book ID 16 | * 17 | * @var intger 18 | */ 19 | protected $abId; 20 | 21 | /** 22 | * Database connection to the NextCloud database 23 | * 24 | * @var rcube_db 25 | */ 26 | protected $db; 27 | 28 | /** 29 | * Database table prefix 30 | * 31 | * @var string 32 | */ 33 | protected $prefix = 'oc_'; 34 | 35 | /** 36 | * Result of the last operation 37 | * 38 | * @var rcube_result_set 39 | */ 40 | protected $result; 41 | 42 | /** 43 | * Stored SQL filter to limit record list 44 | * 45 | * @var string 46 | */ 47 | protected $filter = null; 48 | 49 | /** 50 | * Set required parameters 51 | * 52 | * @param int $abId Addressbook ID (oc_addressbooks.id) 53 | * @param rcube_db $db Connection to the NextCloud database 54 | * @param string $prefix Database table prefix 55 | */ 56 | public function __construct($abId, $db, $prefix) 57 | { 58 | $this->abId = $abId; 59 | $this->db = $db; 60 | $this->prefix = $prefix; 61 | } 62 | 63 | /** 64 | * Get the title of this address book 65 | * 66 | * Used in contact details view. 67 | * 68 | * @return string Address book name 69 | */ 70 | public function get_name() 71 | { 72 | $sql = 'SELECT displayname' 73 | . ' FROM ' . $this->prefix . 'addressbooks' 74 | . ' WHERE id = ?'; 75 | $stmt = $this->db->query($sql, $this->abId); 76 | $row = $this->db->fetch_assoc($stmt); 77 | 78 | return $row['displayname'] . ' (Nextcloud)'; 79 | } 80 | 81 | /** 82 | * Save a search string for future listings. 83 | * 84 | * Needed to share the filter between search(), list_records() and count(). 85 | * 86 | * @param string $filter Part of the SQL statement used to filter contacts 87 | * 88 | * @return void 89 | */ 90 | public function set_search_set($filter) 91 | { 92 | $this->filter = $filter; 93 | } 94 | 95 | /** 96 | * Getter for saved search properties 97 | * 98 | * @return string Filtering part of the contact-fetching SQL statement 99 | */ 100 | public function get_search_set() 101 | { 102 | return $this->filter; 103 | } 104 | 105 | /** 106 | * Reset saved results and search parameters 107 | * 108 | * @return void 109 | */ 110 | public function reset() 111 | { 112 | $this->result = null; 113 | $this->filter = null; 114 | $this->cache = null; 115 | } 116 | 117 | /** 118 | * List the current set of contact records 119 | * 120 | * @param array $cols List of cols to show, NULL means all 121 | * Known values: 122 | * - name 123 | * - firstname 124 | * - surname 125 | * - email 126 | * @param int $subset Only return this number of records, 127 | * use negative values for tail 128 | * @param boolean $nocount Do not calculate the number of all records 129 | * 130 | * @return rcube_result_set 131 | * 132 | * @internal Paging information is stored in $this->list_page 133 | * and starts with 1 134 | */ 135 | public function list_records($cols = null, $subset = 0, $nocount = false) 136 | { 137 | $this->result = new rcube_result_set(); 138 | 139 | $sql = <<filter ? ' AND ' . $this->filter : '', 159 | $this->replaceTablePrefix($sql) 160 | ); 161 | 162 | $firstRecord = $this->list_page * $this->page_size - $this->page_size; 163 | $stmt = $this->db->limitquery( 164 | $sql, 165 | $firstRecord, $this->page_size, 166 | $this->abId 167 | ); 168 | foreach ($stmt as $row) { 169 | $this->result->add( 170 | [ 171 | 'ID' => $row['id'], 172 | 'name' => $row['name'], 173 | 'email' => $row['email'], 174 | ] 175 | ); 176 | } 177 | 178 | if ($nocount) { 179 | //do not fetch the numer of all records 180 | $this->result->count = count($this->result->records); 181 | } else { 182 | $this->result->count = $this->count()->count; 183 | } 184 | 185 | return $this->result; 186 | } 187 | 188 | /** 189 | * Search records 190 | * 191 | * @param array|string $fields One or more field names to search in. Examples: 192 | * - '*' 193 | * - 'ID' 194 | * @param array|string $value Search value 195 | * @param int $mode Search mode. Sum of self::SEARCH_*. 196 | * @param boolean $select False: only count records; do not select them 197 | * @param boolean $nocount True to not calculate the total record count 198 | * @param array $required List of fields that cannot be empty 199 | * 200 | * @return rcube_result_set List of contact records and 'count' value 201 | */ 202 | public function search( 203 | $fields, $value, $mode = 0, $select = true, 204 | $nocount = false, $required = [] 205 | ) { 206 | $where = $this->buildSearchQuery($fields, $value, $mode); 207 | if (empty($where)) { 208 | return new rcube_result_set(); 209 | } 210 | 211 | $this->set_search_set($where); 212 | if ($select) { 213 | return $this->list_records(null, 0, $nocount); 214 | } else { 215 | return $this->count(); 216 | } 217 | } 218 | 219 | /** 220 | * Build an SQL WHERE clause to search for $value 221 | * 222 | * TODO: We do not support space-separated search words yet 223 | * 224 | * @param array|string $fields One or more field names to search in. 225 | * Examples: 226 | * - '*' 227 | * - 'ID' 228 | * @param array|string $value Search value 229 | * @param int $mode Search mode. Sum of self::SEARCH_*. 230 | * 231 | * @return string Part of an SQL query, but without the prefixed " AND " 232 | */ 233 | protected function buildSearchQuery($fields, $value, $mode) 234 | { 235 | if ($fields === 'ID') { 236 | return 'p_email.cardid = ' . intval($value); 237 | 238 | } else if ($fields === '*') { 239 | return '(' 240 | . $this->buildSearchQueryField('name', $value, $mode) 241 | . ' OR ' 242 | . $this->buildSearchQueryField('email', $value, $mode) 243 | . ')'; 244 | } 245 | 246 | $fields = (array) $fields; 247 | $sqlParts = []; 248 | foreach ($fields as $field) { 249 | if ($field != 'name' && $field != 'email') { 250 | continue; 251 | } 252 | 253 | $sqlParts[] = $this->buildSearchQueryField($field, $value, $mode); 254 | } 255 | return '(' . implode(' OR ', $sqlParts) . ')'; 256 | } 257 | 258 | /** 259 | * Build a search SQL for a single field 260 | * 261 | * @param string $field Field name. Examples: 262 | * - '*' 263 | * - 'ID' 264 | * @param array|string $value Search value 265 | * @param int $mode Search mode. Sum of self::SEARCH_*. 266 | * 267 | * @return string Part of an SQL query 268 | */ 269 | protected function buildSearchQueryField($field, $value, $mode) 270 | { 271 | $sqlField = 'p_' . $field . '.value'; 272 | 273 | if ($mode & self::SEARCH_STRICT) { 274 | //exact match 275 | return $sqlField . ' = ' . $this->db->quote($value); 276 | 277 | } else if ($mode & self::SEARCH_PREFIX) { 278 | return $this->db->ilike($sqlField, $value . '%'); 279 | } 280 | 281 | return $this->db->ilike($sqlField, '%' . $value . '%'); 282 | } 283 | 284 | /** 285 | * Count number of available contacts in database 286 | * 287 | * @return rcube_result_set Result set with values for 'count' and 'first' 288 | */ 289 | public function count() 290 | { 291 | $count = isset($this->cache['count']) 292 | ? $this->cache['count'] 293 | : $this->_count(); 294 | 295 | return new rcube_result_set( 296 | $count, ($this->list_page - 1) * $this->page_size 297 | ); 298 | } 299 | 300 | /** 301 | * Count number of available contacts in database 302 | * 303 | * @return int Contacts count 304 | */ 305 | protected function _count() 306 | { 307 | $sql = <<filter ? ' AND ' . $this->filter : '', 323 | $this->replaceTablePrefix($sql) 324 | ); 325 | 326 | $stmt = $this->db->query($sql, $this->abId); 327 | $row = $this->db->fetch_assoc($stmt); 328 | 329 | $this->cache['count'] = (int) $row['cnt']; 330 | return $this->cache['count']; 331 | } 332 | 333 | /** 334 | * Return the last result set 335 | * 336 | * @return rcube_result_set Current result set or NULL if nothing selected yet 337 | */ 338 | public function get_result() 339 | { 340 | return $this->result; 341 | } 342 | 343 | /** 344 | * Get a specific contact record 345 | * 346 | * @param mixed $id Record identifier 347 | * @param boolean $assoc True to return record as associative array. 348 | * False: a result set is returned 349 | * 350 | * @return rcube_result_set|array|null Result object with all record fields 351 | * NULL when it does not exist/ 352 | * is not accessible 353 | */ 354 | public function get_record($id, $assoc = false) 355 | { 356 | $sql = <<db->query( 374 | $this->replaceTablePrefix($sql), 375 | $this->abId, $id 376 | ); 377 | $row = $this->db->fetch_assoc($stmt); 378 | 379 | if ($row === false) { 380 | return null; 381 | } 382 | 383 | $this->result = new rcube_result_set(1); 384 | $this->result->add( 385 | [ 386 | 'ID' => $row['id'], 387 | 'name' => $row['name'], 388 | 'email' => $row['email'], 389 | ] 390 | ); 391 | 392 | return $assoc ? $this->result->first() : $this->result; 393 | } 394 | 395 | /** 396 | * Replace the %PREFIX% variable in SQL queries with the configured 397 | * NextCloud table prefix 398 | * 399 | * @param string $sql SQL query with %PREFIX% variables 400 | * 401 | * @return string Working SQL query 402 | */ 403 | protected function replaceTablePrefix($sql) 404 | { 405 | return str_replace('%PREFIX%', $this->prefix, $sql); 406 | } 407 | } 408 | ?> 409 | --------------------------------------------------------------------------------