1<?php
2/**
3 * Copyright 2013-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file COPYING for license information (LGPL). If you
6 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
7 *
8 * @category  Horde
9 * @copyright 2013-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
11 * @package   Imap_Client
12 */
13
14/**
15 * A Horde_HashTable implementation for caching IMAP/POP data.
16 * Requires the Horde_HashTable and Horde_Pack packages.
17 *
18 * @author    Michael Slusarz <slusarz@horde.org>
19 * @category  Horde
20 * @copyright 2013-2017 Horde LLC
21 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
22 * @package   Imap_Client
23 * @since     2.17.0
24 */
25class Horde_Imap_Client_Cache_Backend_Hashtable
26extends Horde_Imap_Client_Cache_Backend
27{
28    /** Separator for CID between mailbox and UID. */
29    const CID_SEPARATOR = '|';
30
31    /**
32     * The working data for the current pageload. All changes take place to
33     * this data.
34     *
35     * @var array
36     */
37    protected $_data = array();
38
39    /**
40     * HashTable object.
41     *
42     * @var Horde_HashTable
43     */
44    protected $_hash;
45
46    /**
47     * Mailbox level data.
48     *
49     * @var array
50     */
51    protected $_mbox = array();
52
53    /**
54     * Horde_Pack singleton object.
55     *
56     * @var Horde_Pack
57     */
58    protected $_pack;
59
60    /**
61     * List of mailbox/UIDs to update.
62     * Keys are mailboxes. Values are arrays with three possible keys:
63     * <pre>
64     *   - d: UIDs to delete
65     *   - m: Was metadata updated?
66     *   - u: UIDs to update
67     * </pre>
68     *
69     * @var array
70     */
71    protected $_update = array();
72
73    /**
74     * Constructor.
75     *
76     * @param array $params  Configuration parameters:
77     * <pre>
78     *   - REQUIRED parameters:
79     *     - hashtable: (Horde_HashTable) A HashTable object.
80     *
81     *   - Optional Parameters:
82     *     - lifetime: (integer) The lifetime of the cache data (in seconds).
83     *                 DEFAULT: 604800 seconds (1 week) [@since 2.19.0]
84     * </pre>
85     */
86    public function __construct(array $params = array())
87    {
88        if (!isset($params['hashtable'])) {
89            throw new InvalidArgumentException('Missing hashtable parameter.');
90        }
91
92        parent::__construct(array_merge(array(
93            'lifetime' => 604800
94        ), $params));
95    }
96
97    /**
98     */
99    protected function _initOb()
100    {
101        $this->_hash = $this->_params['hashtable'];
102        $this->_pack = new Horde_Pack();
103        register_shutdown_function(array($this, 'save'));
104    }
105
106    /**
107     */
108    public function get($mailbox, $uids, $fields, $uidvalid)
109    {
110        $ret = array();
111
112        if (empty($uids)) {
113            return $ret;
114        }
115
116        $this->_loadUids($mailbox, $uids, $uidvalid);
117
118        if (empty($this->_data[$mailbox])) {
119            return $ret;
120        }
121
122        if (!empty($fields)) {
123            $fields = array_flip($fields);
124        }
125        $ptr = &$this->_data[$mailbox];
126        $to_delete = array();
127
128        foreach ($uids as $val) {
129            if (isset($ptr[$val])) {
130                if (is_string($ptr[$val])) {
131                    try {
132                        $ptr[$val] = $this->_pack->unpack($ptr[$val]);
133                    } catch (Horde_Pack_Exception $e) {
134                        $to_delete[] = $val;
135                        continue;
136                    }
137                }
138
139                $ret[$val] = (empty($fields) || empty($ptr[$val]))
140                    ? $ptr[$val]
141                    : array_intersect_key($ptr[$val], $fields);
142            } else {
143                $to_delete[] = $val;
144            }
145        }
146
147        $this->deleteMsgs($mailbox, $to_delete);
148
149        return $ret;
150    }
151
152    /**
153     */
154    public function getCachedUids($mailbox, $uidvalid)
155    {
156        $this->_loadMailbox($mailbox, $uidvalid);
157        return $this->_mbox[$mailbox]['u']->ids;
158    }
159
160    /**
161     */
162    public function set($mailbox, $data, $uidvalid)
163    {
164        $this->_loadUids($mailbox, array_keys($data), $uidvalid);
165
166        $d = &$this->_data[$mailbox];
167        $to_add = array();
168
169        foreach ($data as $k => $v) {
170            if (isset($d[$k]) && is_string($d[$k])) {
171                try {
172                    $d[$k] = $this->_pack->unpack($d[$k]);
173                } catch (Horde_Pack_Exception $e) {
174                    continue;
175                }
176            }
177
178            $d[$k] = (isset($d[$k]) && is_array($d[$k]))
179                ? array_merge($d[$k], $v)
180                : $v;
181            $this->_update[$mailbox]['u'][$k] = true;
182            unset($this->_update[$mailbox]['d'][$k]);
183            $to_add[] = $k;
184        }
185
186        if (!empty($to_add)) {
187            $this->_mbox[$mailbox]['u']->add($to_add);
188            $this->_update[$mailbox]['m'] = true;
189        }
190    }
191
192    /**
193     */
194    public function getMetaData($mailbox, $uidvalid, $entries)
195    {
196        $this->_loadMailbox($mailbox, $uidvalid);
197
198        return empty($entries)
199            ? $this->_mbox[$mailbox]['d']
200            : array_intersect_key($this->_mbox[$mailbox]['d'], array_flip($entries));
201    }
202
203    /**
204     */
205    public function setMetaData($mailbox, $data)
206    {
207        $this->_loadMailbox($mailbox, isset($data['uidvalid']) ? $data['uidvalid'] : null);
208
209        $this->_mbox[$mailbox]['d'] = array_merge(
210            $this->_mbox[$mailbox]['d'],
211            $data
212        );
213        $this->_update[$mailbox]['m'] = true;
214    }
215
216    /**
217     */
218    public function deleteMsgs($mailbox, $uids)
219    {
220        if (empty($uids)) {
221            return;
222        }
223
224        $this->_loadMailbox($mailbox);
225
226        foreach ($uids as $val) {
227            unset(
228                $this->_data[$mailbox][$val],
229                $this->_update[$mailbox]['u'][$val]
230            );
231            $this->_update[$mailbox]['d'][$val] = true;
232        }
233
234        $this->_mbox[$mailbox]['u']->remove($uids);
235        $this->_update[$mailbox]['m'] = true;
236    }
237
238    /**
239     */
240    public function deleteMailbox($mailbox)
241    {
242        /* Do this action immediately, instead of at shutdown. Makes coding
243         * simpler. */
244        $this->_loadMailbox($mailbox);
245
246        $this->_hash->delete(array_merge(
247            array($this->_getCid($mailbox)),
248            array_values($this->_getMsgCids($mailbox, $this->_mbox[$mailbox]['u']))
249        ));
250
251        unset(
252            $this->_data[$mailbox],
253            $this->_mbox[$mailbox],
254            $this->_update[$mailbox]
255        );
256    }
257
258    /**
259     */
260    public function clear($lifetime)
261    {
262        /* Only can clear mailboxes we know about. */
263        foreach (array_keys($this->_mbox) as $val) {
264            $this->deleteMailbox($val);
265        }
266
267        $this->_data = $this->_mbox = $this->_update = array();
268    }
269
270    /**
271     * Updates the cache.
272     */
273    public function save()
274    {
275        foreach ($this->_update as $mbox => $val) {
276            try {
277                if (!empty($val['u'])) {
278                     $ptr = &$this->_data[$mbox];
279                     foreach ($this->_getMsgCids($mbox, array_keys($val['u'])) as $k2 => $v2) {
280                         try {
281                             $this->_hash->set(
282                                 $v2,
283                                 $this->_pack->pack($ptr[$k2]),
284                                 array('expire' => $this->_params['lifetime'])
285                             );
286                         } catch (Horde_Pack_Exception $e) {
287                             $this->deleteMsgs($mbox, array($v2));
288                             $val['d'][] = $v2;
289                         }
290                     }
291                 }
292
293                 if (!empty($val['d'])) {
294                     $this->_hash->delete(array_values(
295                         $this->_getMsgCids($mbox, $val['d'])
296                     ));
297                 }
298
299                 if (!empty($val['m'])) {
300                     try {
301                         $this->_hash->set(
302                             $this->_getCid($mbox),
303                             $this->_pack->pack($this->_mbox[$mbox]),
304                             array('expire' => $this->_params['lifetime'])
305                         );
306                     } catch (Horde_Pack_Exception $e) {}
307                 }
308            } catch (Horde_Exception $e) {
309            }
310        }
311
312        $this->_update = array();
313    }
314
315    /**
316     * Loads basic mailbox information.
317     *
318     * @param string $mailbox    The mailbox to load.
319     * @param integer $uidvalid  The IMAP uidvalidity value of the mailbox.
320     */
321    protected function _loadMailbox($mailbox, $uidvalid = null)
322    {
323        if (!isset($this->_mbox[$mailbox]) &&
324            ($ob = $this->_hash->get($this->_getCid($mailbox)))) {
325            try {
326                $this->_mbox[$mailbox] = $this->_pack->unpack($ob);
327            } catch (Horde_Pack_Exception $e) {}
328        }
329
330        if (isset($this->_mbox[$mailbox])) {
331            if (is_null($uidvalid) ||
332                ($uidvalid == $this->_mbox[$mailbox]['d']['uidvalid'])) {
333                return;
334            }
335            $this->deleteMailbox($mailbox);
336        }
337
338        $this->_mbox[$mailbox] = array(
339            // Metadata storage
340            // By default includes UIDVALIDITY of mailbox.
341            'd' => array('uidvalid' => $uidvalid),
342            // List of UIDs
343            'u' => new Horde_Imap_Client_Ids()
344        );
345    }
346
347    /**
348     * Load UIDs by regenerating from the cache.
349     *
350     * @param string $mailbox    The mailbox to load.
351     * @param array $uids        The UIDs to load.
352     * @param integer $uidvalid  The IMAP uidvalidity value of the mailbox.
353     */
354    protected function _loadUids($mailbox, $uids, $uidvalid = null)
355    {
356        if (!isset($this->_data[$mailbox])) {
357            $this->_data[$mailbox] = array();
358        }
359
360        $this->_loadMailbox($mailbox, $uidvalid);
361
362        if (empty($uids)) {
363            return;
364        }
365
366        $ptr = &$this->_data[$mailbox];
367
368        $load = array_flip(
369            array_diff_key(
370                $this->_getMsgCids(
371                    $mailbox,
372                    array_unique(array_intersect($this->_mbox[$mailbox]['u']->ids, $uids))
373                ),
374                $this->_data[$mailbox]
375            )
376        );
377
378        foreach (array_filter($this->_hash->get(array_keys($load))) as $key => $val) {
379            $ptr[$load[$key]] = $val;
380        }
381    }
382
383    /**
384     * Create the unique ID used to store the mailbox data in the cache.
385     *
386     * @param string $mailbox  The mailbox to cache.
387     *
388     * @return string  The cache ID.
389     */
390    protected function _getCid($mailbox)
391    {
392        return implode(self::CID_SEPARATOR, array(
393            'horde_imap_client',
394            $this->_params['username'],
395            $mailbox,
396            $this->_params['hostspec'],
397            $this->_params['port']
398        ));
399    }
400
401    /**
402     * Return a list of cache IDs for mailbox/UID pairs.
403     *
404     * @param string $mailbox  The mailbox to cache.
405     * @param array $ids       The UID list.
406     *
407     * @return array  List of UIDs => cache IDs.
408     */
409    protected function _getMsgCids($mailbox, $ids)
410    {
411        $cid = $this->_getCid($mailbox);
412        $out = array();
413
414        foreach ($ids as $val) {
415            $out[$val] = $cid . self::CID_SEPARATOR . $val;
416        }
417
418        return $out;
419    }
420
421}
422