1<?php
2/**
3 * Copyright 2000-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file LICENSE for license information (ASL).  If you did
6 * did not receive this file, see http://www.horde.org/licenses/apache.
7 *
8 * @category  Horde
9 * @copyright 2000-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/apache ASL
11 * @package   Turba
12 */
13
14/**
15 * Turba Base Class.
16 *
17 * @author    Chuck Hagenbuch <chuck@horde.org>
18 * @author    Jon Parise <jon@horde.org>
19 * @category  Horde
20 * @copyright 2000-2017 Horde LLC
21 * @license   http://www.horde.org/licenses/apache ASL
22 * @package   Turba
23 */
24class Turba
25{
26    /**
27     * The virtual path to use for VFS data.
28     */
29    const VFS_PATH = '.horde/turba/documents';
30
31    /**
32     * The current source.
33     *
34     * @var string
35     */
36    static public $source;
37
38    /**
39     * Cached data.
40     *
41     * @var array
42     */
43    static protected $_cache = array();
44
45    /**
46     * Returns the source entries from config/backends.php that have been
47     * configured as available sources in the main Turba configuration.
48     *
49     * @return array  List of available sources.
50     * @throws Horde_Exception
51     */
52    static public function availableSources()
53    {
54        global $registry;
55
56        $s = $registry->loadConfigFile('backends.php', 'cfgSources', 'turba')->config['cfgSources'];
57
58        $sources = array();
59        foreach ($s as $key => $source) {
60            if (empty($source['disabled'])) {
61                $sources[$key] = $source;
62            }
63        }
64
65        return $sources;
66    }
67
68    /**
69     * Get all the address books the user has the requested permissions to and
70     * return them in the user's preferred order.
71     *
72     * @param integer $permission  The Horde_Perms::* constant to filter on.
73     * @param array $options       Any additional options.
74     *
75     * @return array  The filtered, ordered $cfgSources entries.
76     */
77    static public function getAddressBooks($permission = Horde_Perms::READ,
78                                           array $options = array())
79    {
80        return self::permissionsFilter(
81            $GLOBALS['cfgSources'],
82            $permission,
83            $options
84        );
85    }
86
87    /**
88     * Returns the current user's default address book.
89     *
90     * @return string  The default address book name.
91     */
92    static public function getDefaultAddressbook()
93    {
94        /* In case of shares select first user owned address book as default */
95        if (!empty($_SESSION['turba']['has_share'])) {
96            try {
97                $owned_shares = self::listShares(true);
98                if (count($owned_shares)) {
99                    return key($owned_shares);
100                }
101            } catch (Exception $e) {}
102        }
103
104        reset($GLOBALS['cfgSources']);
105        return key($GLOBALS['cfgSources']);
106    }
107
108    /**
109     * Returns the sort order selected by the user.
110     *
111     * @return array  TODO
112     */
113    static public function getPreferredSortOrder()
114    {
115        return @unserialize($GLOBALS['prefs']->getValue('sortorder'));
116    }
117
118    /**
119     * Saves the sort order to the preferences backend.
120     *
121     * @param Horde_Variables $vars  Variables object.
122     * @param string $source         Source.
123     */
124    static public function setPreferredSortOrder(Horde_Variables $vars,
125                                                 $source)
126    {
127        if (!strlen($sortby = $vars->get('sortby'))) {
128            return;
129        }
130
131        $sources = self::getColumns();
132        $columns = isset($sources[$source])
133            ? $sources[$source]
134            : array();
135        $column_name = self::getColumnName($sortby, $columns);
136
137        $append = true;
138        $ascending = ($vars->get('sortdir') == 0);
139
140        if ($vars->get('sortadd')) {
141            $sortorder = self::getPreferredSortOrder();
142            foreach ($sortorder as $i => $elt) {
143                if ($elt['field'] == $column_name) {
144                    $sortorder[$i]['ascending'] = $ascending;
145                    $append = false;
146                }
147            }
148        } else {
149            $sortorder = array();
150        }
151
152        if ($append) {
153            $sortorder[] = array(
154                'ascending' => $ascending,
155                'field' => $column_name
156            );
157        }
158
159        $GLOBALS['prefs']->setValue('sortorder', serialize($sortorder));
160    }
161
162    /**
163     * Retrieves a column's field name.
164     *
165     * @param integer $i      TODO
166     * @param array $columns  TODO
167     *
168     * @return string  TODO
169     */
170    static public function getColumnName($i, $columns)
171    {
172        return (($i == 0) || !isset($columns[$i - 1]))
173            ? 'name'
174            : $columns[$i - 1];
175    }
176
177    /**
178     * TODO
179     */
180    static public function getColumns()
181    {
182        $columns = array();
183        $lines = explode("\n", $GLOBALS['prefs']->getValue('columns'));
184
185        foreach ($lines as $line) {
186            $line = trim($line);
187            if ($line) {
188                $cols = explode("\t", $line);
189                if (count($cols) > 1) {
190                    $source = array_splice($cols, 0, 1);
191                    $columns[$source[0]] = array();
192                    foreach ($cols as $col) {
193                        if ($col == '__tags' ||
194                            isset($GLOBALS['cfgSources'][$source[0]]['map'][$col])) {
195                            $columns[$source[0]][] = $col;
196                        }
197                    }
198                }
199            }
200        }
201
202        return $columns;
203    }
204
205    /**
206     * Builds and cleans up a composite field.
207     *
208     * @param string $format  The sprintf field format.
209     * @param array $fields   The fields that compose the composite field.
210     *
211     * @return string  The formatted composite field.
212     */
213    static public function formatCompositeField($format, $fields)
214    {
215        return preg_replace('/ +/', ' ', trim(vsprintf($format, $fields), " \t\n\r\0\x0B,"));
216    }
217
218    /**
219     * Returns a best guess at the lastname in a string.
220     *
221     * @param string $name  String contain the full name.
222     *
223     * @return string  String containing the last name.
224     */
225    static public function guessLastname($name)
226    {
227        $name = trim(preg_replace('|\s|', ' ', $name));
228        if (!empty($name)) {
229            /* Assume that last names are always before any commas. */
230            if (is_int(strpos($name, ','))) {
231                $name = Horde_String::substr($name, 0, strpos($name, ','));
232            }
233
234            /* Take out anything in parentheses. */
235            $name = trim(preg_replace('|\(.*\)|', '', $name));
236
237            $namelist = explode(' ', $name);
238            $name = $namelist[($nameindex = (count($namelist) - 1))];
239
240            while (!empty($name) &&
241                   (($nlength = Horde_String::length($name)) < 5) &&
242                   strspn($name[($nlength - 1)], '.:-') &&
243                   !empty($namelist[($nameindex - 1)])) {
244                $name = $namelist[--$nameindex];
245            }
246        }
247
248        return strlen($name)
249            ? $name
250            : null;
251    }
252
253    /**
254     * Formats the name according to the user's preference.
255     *
256     * If the format is 'none', the full name with all parts is returned. If
257     * the format is 'last_first' or 'first_last', only the first name and
258     * last name are returned.
259     *
260     * @param Turba_Object $ob     The object to get a name from.
261     * @param string $name_format  The formatting. One of 'none', 'last_first'
262     *                             or 'first_last'. Defaults to the user
263     *                             preference.
264     *
265     * @return string  The formatted name, either "Firstname Lastname"
266     *                 or "Lastname, Firstname" depending on $name_format or
267     *                 the user's preference.
268     */
269    static public function formatName(Turba_Object $ob, $name_format = null)
270    {
271        if (!$name_format) {
272            if (!isset(self::$_cache['defaultFormat'])) {
273                self::$_cache['defaultFormat'] = $GLOBALS['prefs']->getValue('name_format');
274            }
275            $name_format = self::$_cache['defaultFormat'];
276        }
277
278        /* If no formatting, return original name. */
279        if (!in_array($name_format, array('first_last', 'last_first'))) {
280            return $ob->getValue('name');
281        }
282
283        /* See if we have the name fields split out explicitly. */
284        if ($ob->hasValue('firstname') && $ob->hasValue('lastname')) {
285            return ($name_format == 'last_first')
286                ? $ob->getValue('lastname') . ', ' . $ob->getValue('firstname')
287                : $ob->getValue('firstname') . ' ' . $ob->getValue('lastname');
288        }
289
290        /* One field, we'll have to guess. */
291        $name = $ob->getValue('name');
292        $lastname = self::guessLastname($name);
293        if (($name_format == 'last_first') &&
294            !is_int(strpos($name, ',')) &&
295            (Horde_String::length($name) > Horde_String::length($lastname))) {
296            return $lastname . ', ' . preg_replace('/\s+' . preg_quote($lastname, '/') . '/', '', $name);
297        }
298
299        if (($name_format == 'first_last') &&
300            is_int(strpos($name, ',')) &&
301            (Horde_String::length($name) > Horde_String::length($lastname))) {
302            return preg_replace('/' . preg_quote($lastname, '/') . ',\s*/', '', $name) . ' ' . $lastname;
303        }
304
305        return $name;
306    }
307
308    /**
309     * TODO
310     *
311     * @param mixed $data   Either a single email address or an array of email
312     *                      addresses to format.
313     * @param string $name  The personal name phrase.
314     *
315     * @return mixed  Either the formatted address or an array of formatted
316     *                addresses.
317     */
318    static public function formatEmailAddresses($data, $name)
319    {
320        if (!isset(self::$_cache['useRegistry'])) {
321            self::$_cache['useRegistry'] = $GLOBALS['registry']->hasMethod('mail/batchCompose');
322        }
323
324        $out = array();
325        $rfc822 = $GLOBALS['injector']->getInstance('Horde_Mail_Rfc822');
326
327        if (!is_array($data)) {
328            $data = array($data);
329        }
330
331        foreach ($data as $email_vals) {
332            foreach ($rfc822->parseAddressList($email_vals) as $ob) {
333                $addr = strval($ob);
334                $tmp = null;
335
336                if (self::$_cache['useRegistry']) {
337                    try {
338                        $tmp = $GLOBALS['registry']->call('mail/batchCompose', array(array($addr)));
339                    } catch (Horde_Exception $e) {
340                        self::$_cache['useRegistry'] = false;
341                    }
342                }
343
344                $tmp = empty($tmp)
345                    ? 'mailto:' . urlencode($addr)
346                    : reset($tmp);
347
348                $out[] = Horde::link($tmp) . htmlspecialchars($addr) . '</a>';
349            }
350        }
351
352        return implode(', ', $out);
353    }
354
355    /**
356     * Returns the real name, if available, of a user.
357     *
358     * @param string $uid  The uid of the name to return.
359     *
360     * @return string  The user's full, real name.
361     */
362    static public function getUserName($uid)
363    {
364        if (!isset(self::$_cache['names'])) {
365            self::$_cache['names'] = array();
366        }
367
368        if (!isset(self::$_cache['names'][$uid])) {
369            $ident = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Identity')->create($uid);
370            $ident->setDefault($ident->getDefault());
371            $name = $ident->getValue('fullname');
372            self::$_cache['names'][$uid] = empty($name)
373                ? $uid
374                : $name;
375        }
376
377        return self::$_cache['names'][$uid];
378    }
379
380    /**
381     * Gets extended permissions on an address book.
382     *
383     * @param Turba_Driver $addressBook  The address book to get extended
384     *                                   permissions for.
385     * @param string $permission         What extended permission to get.
386     *
387     * @return mixed  The requested extended permissions value, or true if it
388     *                doesn't exist.
389     */
390    static public function getExtendedPermission(Turba_Driver $addressBook,
391                                                 $permission)
392    {
393        // We want to check the base source as extended permissions
394        // are enforced per backend, not per share.
395        $key = $addressBook->getName() . ':' . $permission;
396
397        $perms = $GLOBALS['injector']->getInstance('Horde_Perms');
398        if (!$perms->exists('turba:sources:' . $key)) {
399            return true;
400        }
401
402        $allowed = $perms->getPermissions('turba:sources:' . $key, $GLOBALS['registry']->getAuth());
403        if (is_array($allowed)) {
404            switch ($permission) {
405            case 'max_contacts':
406                $allowed = max($allowed);
407                break;
408            }
409        }
410        return $allowed;
411    }
412
413    /**
414     * Filters sources based on permissions.
415     *
416     * @param array $in            The source list we want filtered.
417     * @param integer $permission  The Horde_Perms::* constant we will filter
418     *                             on.
419     * @param array $options       Additional options:
420     *                             - require_add: (boolean) Only return sources
421     *                               that can be added to.
422     *
423     * @return array  The filtered data.
424     */
425    static public function permissionsFilter(array $in,
426                                             $permission = Horde_Perms::READ,
427                                             array $options = array())
428    {
429        $factory = $GLOBALS['injector']->getInstance('Turba_Factory_Driver');
430        $out = array();
431
432        foreach ($in as $sourceId => $source) {
433            try {
434                $driver = $factory->create($source, $sourceId);
435                if ($driver->hasPermission($permission) &&
436                    (empty($options['require_add']) || $driver->canAdd())) {
437                    $out[$sourceId] = $source;
438                }
439            } catch (Turba_Exception $e) {
440                Horde::log($e, 'ERR');
441            }
442        }
443
444        return $out;
445    }
446
447    /**
448     * Replaces all share-enabled sources in a source list with all shares
449     * from this source that the current user has access to.
450     *
451     * This will only sync shares that are unique to Horde (such as a SQL or
452     * Kolab sources).  Any backend that supports ACLs or similar mechanism
453     * should be configured from within backends.local.php or via Horde's
454     * share_* hooks.
455     *
456     * @param array $sources  The default $cfgSources array.
457     * @param boolean $owner  Only return shares that the current user owns?
458     * @param array $options  An array of options:
459     *        - shares: Use this list of provided shares. Default is to get the
460     *                  list from the share system using the current user.
461     *        - auth_user: Use this as the authenticated user name.
462     *
463     * @return array  The $cfgSources array.
464     */
465    public static function getConfigFromShares(array $sources, $owner = false, $options = array())
466    {
467        global $notification, $registry, $conf, $injector, $prefs;
468
469        if (empty($options['shares'])) {
470            try {
471                $shares = self::listShares($owner);
472            } catch (Horde_Share_Exception $e) {
473                // Notify the user if we failed, but still return the $cfgSource
474                // array.
475                $notification->push($e, 'horde.error');
476                return $sources;
477            }
478        } else {
479            $shares = $options['shares'];
480        }
481
482        /* See if any of our sources are configured to handle all otherwise
483         * unassigned Horde_Share objects. */
484        $all_shares = null;
485        foreach ($sources as $key => $cfg) {
486            if (!empty($cfg['all_shares'])) {
487                // Indicate the source handler that catches unassigned shares.
488                $all_shares = $key;
489            }
490        }
491
492        if (empty($options['auth_user'])) {
493            $auth_user = $registry->getAuth();
494        } else {
495            $auth_user = $options['auth_user'];
496        }
497
498        $sortedSources = $vbooks = array();
499        $personal = false;
500
501        foreach ($shares as $name => &$share) {
502            if (isset($sources[$name])) {
503                continue;
504            }
505
506            $personal |= ($share->get('owner') == $auth_user);
507
508            $params = @unserialize($share->get('params'));
509            if (empty($params['source']) && !empty($all_shares)) {
510                $params['source'] = $all_shares;
511            }
512
513            if (isset($params['type']) && $params['type'] == 'vbook') {
514                // We load vbooks last in case they're based on other shares.
515                $params['share'] = $share;
516                $vbooks[$name] = $params;
517            } elseif (!empty($params['source']) &&
518                      !empty($sources[$params['source']]['use_shares'])) {
519                if (empty($params['name'])) {
520                    $params['name'] = $name;
521                    $share->set('params', serialize($params));
522                    try {
523                        $share->save();
524                    } catch (Horde_Share_Exception $e) {
525                        Horde::log($e, 'ERR');
526                    }
527                }
528
529                $info = $sources[$params['source']];
530                $info['params']['config'] = $sources[$params['source']];
531                $info['params']['config']['params']['share'] = $share;
532                $info['params']['config']['params']['name'] = $params['name'];
533                $info['title'] = $share->get('name');
534                if ($share->get('owner') != $auth_user) {
535                    $info['title'] .= ' [' . $registry->convertUsername($share->get('owner'), false) . ']';
536                }
537                $info['type'] = 'share';
538                $info['use_shares'] = false;
539                $sortedSources[$params['source']][$name] = $info;
540            }
541        }
542
543        // Check for the user's default share and built new source list.
544        $newSources = array();
545        foreach (array_keys($sources) as $source) {
546            if (empty($sources[$source]['use_shares'])) {
547                $newSources[$source] = $sources[$source];
548                continue;
549            }
550
551            if (isset($sortedSources[$source])) {
552                $newSources = array_merge($newSources, $sortedSources[$source]);
553            }
554
555            if (!empty($conf['share']['auto_create']) &&
556                $auth_user &&
557                !$personal) {
558                // User's default share is missing.
559                try {
560                    $driver = $injector
561                        ->getInstance('Turba_Factory_Driver')
562                        ->create($source);
563                } catch (Turba_Exception $e) {
564                    $notification->push($e->getMessage(), 'horde.error');
565                    continue;
566                }
567
568                $sourceKey = strval(new Horde_Support_Randomid());
569                try {
570                    $share = $driver->createShare(
571                        $sourceKey,
572                        array(
573                            'params' => array(
574                                'source' => $source,
575                                'default' => true,
576                                'name' => $auth_user
577                            )
578                        )
579                    );
580
581                    $source_config = $sources[$source];
582                    $source_config['params']['share'] = $share;
583                    $newSources[$sourceKey] = $source_config;
584                    $personal = true;
585                    $prefs->setValue('default_dir', $share->getName());
586                } catch (Horde_Share_Exception $e) {
587                    Horde::log($e, 'ERR');
588                }
589            }
590        }
591
592        // Add vbooks now that all available address books are loaded.
593        foreach ($vbooks as $name => $params) {
594            if (isset($newSources[$params['source']])) {
595                $newSources[$name] = array(
596                    'title' => $shares[$name]->get('name'),
597                    'type' => 'vbook',
598                    'params' => $params,
599                    'export' => true,
600                    'browse' => true,
601                    'map' => $newSources[$params['source']]['map'],
602                    'search' => $newSources[$params['source']]['search'],
603                    'strict' => $newSources[$params['source']]['strict'],
604                    'use_shares' => false,
605                );
606            } else {
607                $notification->push(sprintf(
608                    _("Removing the virtual address book \"%s\" because the parent source has disappeared."),
609                    $shares[$name]->get('name')), 'horde.message'
610                );
611                try {
612                    $injector->getInstance('Turba_Shares')->removeShare($shares[$name]);
613                } catch (Horde_Share_Exception $e) {
614                    Horde::log($e, 'ERR');
615                }
616            }
617        }
618
619        return $newSources;
620    }
621
622    /**
623     * Retrieve a new source config entry based on a Turba share.
624     *
625     * @param Horde_Share_Object object  The share to base config on.
626     *
627     * @return array  The $cfgSource entry for this share source.
628     */
629    public static function getSourceFromShare(Horde_Share_Object $share)
630    {
631        // Require a fresh config file.
632        $cfgSources = self::availableSources();
633
634        $params = @unserialize($share->get('params'));
635        $newConfig = $cfgSources[$params['source']];
636        $newConfig['params']['config'] = $cfgSources[$params['source']];
637        $newConfig['params']['config']['params']['share'] = $share;
638        $newConfig['params']['config']['params']['name'] = $params['name'];
639        $newConfig['title'] = $share->get('name');
640        $newConfig['type'] = 'share';
641        $newConfig['use_shares'] = false;
642
643        return $newConfig;
644    }
645
646    /**
647     * Returns all shares the current user has specified permissions to.
648     *
649     * @param boolean $owneronly   Only return address books owned by the user?
650     *                             Defaults to false.
651     * @param integer $permission  Permissions to filter by.
652     *
653     * @return array  Shares the user has the requested permissions to.
654     */
655    static public function listShares($owneronly = false,
656                                      $permission = Horde_Perms::READ)
657    {
658        if (!$GLOBALS['session']->get('turba', 'has_share') ||
659            ($owneronly && !$GLOBALS['registry']->getAuth())) {
660            return array();
661        }
662
663        try {
664            return $GLOBALS['injector']->getInstance('Turba_Shares')->listShares(
665                $GLOBALS['registry']->getAuth(),
666                array(
667                    'attributes' => $owneronly ? $GLOBALS['registry']->getAuth() : null,
668                    'perm' => $permission
669                )
670            );
671        } catch (Horde_Share_Exception $e) {
672            Horde::log($e, 'ERR');
673            return array();
674        }
675    }
676
677    /**
678     * Create a new Turba share.
679     *
680     * @param string $share_name  The id for the new share.
681     * @param array  $params      Parameters for the new share.
682     *
683     * @return Horde_Share  The new share object.
684     * @throws Turba_Exception
685     */
686    static public function createShare($share_name, $params)
687    {
688        if (isset($params['name'])) {
689            $name = $params['name'];
690            unset($params['name']);
691        } else {
692            /* Sensible default for empty display names */
693            $name = sprintf(_("Address book of %s"), $GLOBALS['injector']->getInstance('Horde_Core_Factory_Identity')->create()->getName());
694        }
695
696        /* Generate the new share. */
697        try {
698            $turba_shares = $GLOBALS['injector']->getInstance('Turba_Shares');
699
700            $share = $turba_shares->newShare($GLOBALS['registry']->getAuth(), $share_name, $name);
701
702            /* Now any other params. */
703            foreach ($params as $key => $value) {
704                if (!is_scalar($value)) {
705                    $value = serialize($value);
706                }
707                $share->set($key, $value);
708            }
709            $turba_shares->addShare($share);
710            $share->save();
711        } catch (Horde_Share_Exception $e) {
712            Horde::log($e, 'ERR');
713            throw new Turba_Exception($e);
714        }
715
716        return $share;
717    }
718
719    /**
720     * Add browse.js javascript to page.
721     */
722    static public function addBrowseJs()
723    {
724        global $page_output;
725
726        $page_output->addScriptFile('browse.js');
727        $page_output->addInlineJsVars(array(
728            'TurbaBrowse.confirmdelete' => _("Are you sure that you want to delete %s?"),
729            'TurbaBrowse.contact1' => _("You must select at least one contact first."),
730            'TurbaBrowse.contact2' => _("You must select a target contact list."),
731            'TurbaBrowse.contact3' => _("Please name the new contact list:"),
732            'TurbaBrowse.copymove' => _("You must select a target address book."),
733            'TurbaBrowse.submit' => _("Are you sure that you want to delete the selected contacts?")
734        ));
735    }
736
737    /**
738     * Return an array of all available attributes of type 'email'. Optionally,
739     * ensure the field is defined in the specified $source.
740     *
741     * @param $source string       An optional source identifier.
742     * @param $searchable boolean  If true, and $source is provided, ensure that
743     *                             the email field is a configured searchable
744     *                             field.
745     *
746     * @return array  An array of email fields.
747     * @since  4.2.9
748     */
749    public static function getAvailableEmailFields($source = null, $searchable = true)
750    {
751        global $attributes, $injector, $cfgSources;
752
753        if (!empty($source)) {
754            $driver = $injector->getInstance('Turba_Factory_Driver')
755                ->create($source);
756        }
757
758        $emailFields = array();
759        foreach ($attributes as $field => $data) {
760            if ($data['type'] == 'email') {
761                if (empty($source) || (!empty($source) &&
762                    in_array($field, array_keys($driver->map)) &&
763                    (!$searchable || ($searchable && in_array($field, $cfgSources[$source]['search'])))))
764
765                    $emailFields[] = $field;
766            }
767        }
768
769        return $emailFields;
770    }
771
772}
773