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 * A base implementation for Turba objects - people, groups, restaurants, etc.
16 *
17 * @author    Chuck Hagenbuch <chuck@horde.org>
18 * @author    Jon Parise <jon@csh.rit.edu>
19 * @category  Horde
20 * @copyright 2000-2017 Horde LLC
21 * @license   http://www.horde.org/licenses/apache ASL
22 * @package   Turba
23 */
24class Turba_Object
25{
26    /**
27     * Underlying driver.
28     *
29     * @var Turba_Driver
30     */
31    public $driver;
32
33    /**
34     * Hash of attributes for this contact.
35     *
36     * @var array
37     */
38    public $attributes;
39
40    /**
41     * Keeps the normalized values of sort columns.
42     *
43     * @var array
44     */
45    public $sortValue = array();
46
47    /**
48     * Any additional options.
49     *
50     * @var boolean
51     */
52    protected $_options = array();
53
54    /**
55     * Reference to this object's VFS instance.
56     *
57     * @var VFS
58     */
59    protected $_vfs;
60
61    /**
62     * Local cache of available email addresses. Needed to ensure we
63     * populate the email field correctly. See See Bug: 12955 and Bug: 14046.
64     * A hash with turba attribute names as key.
65     *
66     * @var array
67     */
68    protected $_emailFields = array();
69
70    /**
71     * Constructs a new Turba_Object object.
72     *
73     * @param Turba_Driver $driver  The source that this object came from.
74     * @param array $attributes     Hash of attributes for this object.
75     * @param array $options        Hash of options for this object. @since
76     *                              Turba 4.2
77     */
78    public function __construct(Turba_Driver $driver,
79                                array $attributes = array(),
80                                array $options = array())
81    {
82        $this->driver = $driver;
83        $this->attributes = $attributes;
84        $this->attributes['__type'] = 'Object';
85        $this->_options = $options;
86    }
87
88    /**
89     * Returns a key-value hash containing all properties of this object.
90     *
91     * @return array  All properties of this object.
92     */
93    public function getAttributes()
94    {
95        return $this->attributes;
96    }
97
98    /**
99     * Returns the name of the address book that this object is from.
100     */
101    public function getSource()
102    {
103        return $this->driver->getName();
104    }
105
106    /**
107     * Get a fully qualified key for this contact.
108     *
109     * @param string $delimiter Delimiter for the parts of the key, defaults to ':'.
110     *
111     * @return string Fully qualified contact id.
112     */
113    public function getGuid($delimiter = ':')
114    {
115        return 'turba' . $delimiter . $this->getSource() . $delimiter . $this->getValue('__uid');
116    }
117
118    /**
119     * Returns the value of the specified attribute.
120     *
121     * @param string $attribute  The attribute to retrieve.
122     *
123     * @return mixed  The value of $attribute, an array (for photo type)
124     *                or the empty string.
125     */
126    public function getValue($attribute)
127    {
128        global $attributes, $injector;
129
130        if (isset($this->attributes[$attribute]) &&
131            ($hooks = $injector->getInstance('Horde_Core_Hooks')) &&
132            $hooks->hookExists('decode_attribute', 'turba')) {
133            try {
134                return $hooks->callHook(
135                    'decode_attribute',
136                    'turba',
137                    array($attribute, $this->attributes[$attribute], $this)
138                );
139            } catch (Turba_Exception $e) {}
140        } elseif (isset($this->driver->map[$attribute]) &&
141            is_array($this->driver->map[$attribute])) {
142            $args = array();
143            foreach ($this->driver->map[$attribute]['fields'] as $field) {
144                $args[] = $this->getValue($field);
145            }
146            return Turba::formatCompositeField($this->driver->map[$attribute]['format'], $args);
147        } elseif (!isset($this->attributes[$attribute])) {
148            if (isset($attributes[$attribute]) &&
149                ($attributes[$attribute]['type'] == 'Turba:TurbaTags') &&
150                ($uid = $this->getValue('__uid'))) {
151                $this->synchronizeTags($injector->getInstance('Turba_Tagger')->getTags($uid, 'contact'));
152            } else {
153                return null;
154            }
155        } elseif (isset($attributes[$attribute]) &&
156            ($attributes[$attribute]['type'] == 'image')) {
157            return empty($this->attributes[$attribute])
158                ? null
159                : array(
160                      'load' => array(
161                          'data' => $this->attributes[$attribute],
162                          'file' => basename(Horde::getTempFile('horde_form_', false, '', false, true))
163                      )
164                  );
165        }
166
167        return $this->attributes[$attribute];
168    }
169
170    /**
171     * Sets the value of the specified attribute.
172     *
173     * @param string $attribute  The attribute to set.
174     * @param string $value      The value of $attribute.
175     */
176    public function setValue($attribute, $value)
177    {
178        global $injector, $attributes;
179
180        $hooks = $injector->getInstance('Horde_Core_Hooks');
181
182        if ($hooks->hookExists('encode_attribute', 'turba')) {
183            try {
184                $value = $hooks->callHook(
185                    'encode_attribute',
186                    'turba',
187                    array(
188                        $attribute,
189                        $value,
190                        isset($this->attributes[$attribute]) ? $this->attributes[$attribute] : null,
191                        $this
192                    )
193                );
194            } catch (Turba_Exception $e) {}
195        }
196
197        // If we don't know the attribute, it's not a private attribute,
198        // and it's an email field, save it in case we need to populate an email
199        // field on save.
200        if (!isset($this->driver->map[$attribute]) && strpos($attribute, '__') === false) {
201            if (isset($attributes[$attribute]) &&
202                $attributes[$attribute]['type'] == 'email') {
203                $this->_emailFields[$attribute] = $value;
204            }
205            return;
206        }
207
208        $this->attributes[$attribute] = $value;
209    }
210
211    /**
212     * Determines whether or not the object has a value for the specified
213     * attribute.
214     *
215     * @param string $attribute  The attribute to check.
216     *
217     * @return boolean  Whether or not there is a value for $attribute.
218     */
219    public function hasValue($attribute)
220    {
221        if (isset($this->driver->map[$attribute]) &&
222            is_array($this->driver->map[$attribute])) {
223            foreach ($this->driver->map[$attribute]['fields'] as $field) {
224                if ($this->hasValue($field)) {
225                    return true;
226                }
227            }
228            return false;
229        } else {
230            return !is_null($this->getValue($attribute));
231        }
232    }
233
234    /**
235     * Syncronizes tags from the tagging backend with the contacts storage
236     * backend, if necessary.
237     *
238     * @param array $tags  Tags from the tagging backend.
239     */
240    public function synchronizeTags(array $tags)
241    {
242        if (!is_null($internaltags = $this->getValue('__internaltags'))) {
243            $internaltags = unserialize($internaltags);
244            usort($tags, 'strcoll');
245            if (array_diff($internaltags, $tags)) {
246                $GLOBALS['injector']->getInstance('Turba_Tagger')->replaceTags(
247                    $this->getValue('__uid'),
248                    $internaltags,
249                    $this->driver->getContactOwner(),
250                    'contact'
251                );
252            }
253            $this->setValue('__tags', implode(', ', $internaltags));
254        } else {
255            $this->setValue('__tags', implode(', ', $tags));
256        }
257    }
258
259    /**
260     * Returns the timestamp of the last modification, whether this was the
261     * creation or editing of the object and stores it as the attribute
262     * __modified. The value is cached for the lifetime of the object.
263     *
264     * @return integer  The timestamp of the last modification or zero.
265     */
266    public function lastModification()
267    {
268        $time = $this->getValue('__modified');
269        if (!is_null($time)) {
270            return $time;
271        }
272        if (!$this->getValue('__uid')) {
273            $this->setValue('__modified', 0);
274            return 0;
275        }
276        $time = 0;
277        try {
278            $log = $GLOBALS['injector']
279                ->getInstance('Horde_History')
280                ->getHistory($this->getGuid());
281            foreach ($log as $entry) {
282                if ($entry['action'] == 'add' || $entry['action'] == 'modify') {
283
284                    $time = max($time, $entry['ts']);
285                }
286            }
287        } catch (Exception $e) {}
288        $this->setValue('__modified', $time);
289
290        return $time;
291    }
292
293    /**
294     * Merges another contact into this one by filling empty fields of this
295     * contact with values from the other.
296     *
297     * @param Turba_Object $contact  Another contact.
298     */
299    public function merge(Turba_Object $contact)
300    {
301        foreach (array_keys($contact->attributes) as $attribute) {
302            if (!$this->hasValue($attribute) && $contact->hasValue($attribute)) {
303                $this->setValue($attribute, $contact->getValue($attribute));
304            }
305        }
306    }
307
308    /**
309     * Returns history information about this contact.
310     *
311     * @return array  A hash with the optional entries 'created' and 'modified'
312     *                and human readable history information as the values.
313     */
314    public function getHistory()
315    {
316        if (!$this->getValue('__uid')) {
317            return array();
318        }
319        $history = array();
320        try {
321            $log = $GLOBALS['injector']
322                ->getInstance('Horde_History')
323                ->getHistory($this->getGuid());
324            foreach ($log as $entry) {
325                if ($entry['action'] == 'add' || $entry['action'] == 'modify') {
326                    if ($GLOBALS['registry']->getAuth() != $entry['who']) {
327                        $by = sprintf(_("by %s"), Turba::getUserName($entry['who']));
328                    } else {
329                        $by = _("by me");
330                    }
331                    $history[$entry['action'] == 'add' ? 'created' : 'modified']
332                        = strftime($GLOBALS['prefs']->getValue('date_format'), $entry['ts'])
333                        . ' '
334                        . date($GLOBALS['prefs']->getValue('twentyFour') ? 'G:i' : 'g:i a', $entry['ts'])
335                        . ' '
336                        . htmlspecialchars($by);
337                }
338            }
339        } catch (Exception $e) {
340            return array();
341        }
342
343        return $history;
344    }
345
346    /**
347     * Returns true if this object is a group of multiple contacts.
348     *
349     * @return boolean  True if this object is a group of multiple contacts.
350     */
351    public function isGroup()
352    {
353        return false;
354    }
355
356    /**
357     * Returns true if this object is editable by the current user.
358     *
359     * @return boolean  Whether or not the current user can edit this object
360     */
361    public function isEditable()
362    {
363        return $this->driver->hasPermission(Horde_Perms::EDIT);
364    }
365
366    /**
367     * Returns whether or not the current user has the requested permission.
368     *
369     * @param integer $perm  The permission to check.
370     *
371     * @return boolean True if user has the permission.
372     */
373    public function hasPermission($perm)
374    {
375        return $this->driver->hasPermission($perm);
376    }
377
378    /**
379     * Contact url.
380     *
381     * @param string $view   The view for the url
382     * @param boolean $full  Generate a full url?
383     *
384     * @return string
385     */
386    public function url($view = null, $full = false)
387    {
388        $url = Horde::url('contact.php', $full)->add(array(
389            'source' => $this->driver->getName(),
390            'key' => $this->getValue('__key')
391        ));
392
393        if (!is_null($view)) {
394            $url->add('view', $view);
395        }
396
397        return $url;
398    }
399
400    /**
401     * Saves a file into the VFS backend associated with this object.
402     *
403     * @param array $info  A hash with the file information as returned from a
404     *                     Horde_Form_Type_file.
405     * @throws Turba_Exception
406     */
407    public function addFile(array $info)
408    {
409        if (!$this->getValue('__uid')) {
410            throw new Turba_Exception('VFS not supported for this object.');
411        }
412
413        $vfs = $this->vfsInit();
414
415        $dir = Turba::VFS_PATH . '/' . $this->getValue('__uid');
416        $file = $info['name'];
417        while ($vfs->exists($dir, $file)) {
418            if (preg_match('/(.*)\[(\d+)\](\.[^.]*)?$/', $file, $match)) {
419                $file = $match[1] . '[' . ++$match[2] . ']' . $match[3];
420            } else {
421                $dot = strrpos($file, '.');
422                if ($dot === false) {
423                    $file .= '[1]';
424                } else {
425                    $file = substr($file, 0, $dot) . '[1]' . substr($file, $dot);
426                }
427            }
428        }
429        try {
430            $vfs->write($dir, $file, $info['tmp_name'], true);
431        } catch (Horde_Vfs_Exception $e) {
432            throw new Turba_Exception($e);
433        }
434    }
435
436    /**
437     * Deletes a file from the VFS backend associated with this object.
438     *
439     * @param string $file  The file name.
440     * @throws Turba_Exception
441     */
442    public function deleteFile($file)
443    {
444        if (!$this->getValue('__uid')) {
445            throw new Turba_Exception('VFS not supported for this object.');
446        }
447
448        try {
449            $this->vfsInit()->deleteFile(Turba::VFS_PATH . '/' . $this->getValue('__uid'), $file);
450        } catch (Horde_Vfs_Exception $e) {
451            throw new Turba_Exception($e);
452        }
453    }
454
455    /**
456     * Deletes all files from the VFS backend associated with this object.
457     *
458     * @throws Turba_Exception
459     */
460    public function deleteFiles()
461    {
462        if (!$this->getValue('__uid')) {
463            throw new Turba_Exception('VFS not supported for this object.');
464        }
465
466        $vfs = $this->vfsInit();
467
468        if ($vfs->exists(Turba::VFS_PATH, $this->getValue('__uid'))) {
469            try {
470                $vfs->deleteFolder(Turba::VFS_PATH, $this->getValue('__uid'), true);
471            } catch (Horde_Vfs_Exception $e) {
472                throw new Turba_Exception($e);
473            }
474        }
475    }
476
477    /**
478     * Returns all files from the VFS backend associated with this object.
479     *
480     * @return array  A list of hashes with file informations.
481     */
482    public function listFiles()
483    {
484        if ($this->getValue('__uid')) {
485            try {
486                $vfs = $this->vfsInit();
487                if ($vfs->exists(Turba::VFS_PATH, $this->getValue('__uid'))) {
488                    return $vfs->listFolder(Turba::VFS_PATH . '/' . $this->getValue('__uid'));
489                }
490            } catch (Turba_Exception $e) {}
491        }
492
493        return array();
494    }
495
496    /**
497     * Returns a link to display and download a file from the VFS backend
498     * associated with this object.
499     *
500     * @param string $file  The file name.
501     *
502     * @return string  The HTML code of the generated link.
503     */
504    public function vfsDisplayUrl($file)
505    {
506        global $registry;
507
508        $mime_part = new Horde_Mime_Part();
509        $mime_part->setType(Horde_Mime_Magic::extToMime($file['type']));
510        $viewer = $GLOBALS['injector']->getInstance('Horde_Core_Factory_MimeViewer')->create($mime_part);
511
512        // We can always download files.
513        $url_params = array(
514            'actionID' => 'download_file',
515            'file' => $file['name'],
516            'type' => $file['type'],
517            'source' => $this->driver->getName(),
518            'key' => $this->getValue('__key')
519        );
520        $dl = Horde::link($registry->downloadUrl($file['name'], $url_params), $file['name']) . Horde_Themes_Image::tag('download.png', array('alt' => _("Download"))) . '</a>';
521
522        // Let's see if we can view this one, too.
523        if ($viewer && !($viewer instanceof Horde_Mime_Viewer_Default)) {
524            $url = Horde::url('view.php')
525                ->add($url_params)
526                ->add('actionID', 'view_file');
527            $link = Horde::link($url, $file['name'], null, '_blank') . $file['name'] . '</a>';
528        } else {
529            $link = $file['name'];
530        }
531
532        return $link . ' ' . $dl;
533    }
534
535    /**
536     * Returns a link to display, download, and delete a file from the VFS
537     * backend associated with this object.
538     *
539     * @param string $file  The file name.
540     *
541     * @return string  The HTML code of the generated link.
542     */
543    public function vfsEditUrl($file)
544    {
545        $delform = '<form action="' .
546            Horde::url('deletefile.php') .
547            '" style="display:inline" method="post">' .
548            Horde_Util::formInput() .
549            '<input type="hidden" name="file" value="' . htmlspecialchars($file['name']) . '" />' .
550            '<input type="hidden" name="source" value="' . htmlspecialchars($this->driver->getName()) . '" />' .
551            '<input type="hidden" name="key" value="' . htmlspecialchars($this->getValue('__key')) . '" />' .
552            '<input type="image" class="img" src="' . Horde_Themes::img('delete.png') . '" />' .
553            '</form>';
554
555        return $this->vfsDisplayUrl($file) . ' ' . $delform;
556    }
557
558    /**
559     * Saves the current state of the object to the storage backend.
560     *
561     * @throws Turba_Exception
562     */
563    public function store()
564    {
565        $this->_ensureEmail();
566        return $this->setValue('__key', $this->driver->save($this));
567    }
568
569    /**
570     * Loads the VFS configuration and initializes the VFS backend.
571     *
572     * @return Horde_Vfs  A VFS object.
573     * @throws Turba_Exception
574     */
575    public function vfsInit()
576    {
577        if (!isset($this->_vfs)) {
578            try {
579                $this->_vfs = $GLOBALS['injector']->getInstance('Horde_Core_Factory_Vfs')->create('documents');
580            } catch (Horde_Exception $e) {
581                throw new Turba_Exception($e);
582            }
583        }
584
585        return $this->_vfs;
586    }
587
588    /**
589     * Ensure we have an email address set, if available. Needed to cover the
590     * case where a contact might have been imported via vCard with email TYPEs
591     * that do not match the configured attributes for this source. E.g., the
592     * vCard contains a TYPE=HOME but we only have the generic 'email' field
593     * available.
594     *
595     * @return [type] [description]
596     */
597    protected  function _ensureEmail()
598    {
599        global $attributes;
600
601        // If an email type attribute is not known to this object's driver map
602        // then attempt to fill in any email attributes we DO know about that
603        // are currently empty. Not ideal, but if a client is sending unknown
604        // email fields, we have no way of knowing where to put them and this
605        // is better than dropping them.
606        foreach ($this->_emailFields as $attribute => $email) {
607            if (empty($this->driver->map[$attribute]) && $attribute != 'emails') {
608                foreach ($this->driver->map as $driver_att => $driver_value) {
609                    if ($attributes[$driver_att]['type'] == 'email' &&
610                        empty($this->attributes[$driver_att])) {
611                        $this->attributes[$driver_att] = $email;
612                        break;
613                    }
614                }
615            }
616        }
617    }
618
619}
620