1<?php
2/**
3 * Copyright 2005-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file LICENSE 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 2005-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
11 * @package   Imap_Client
12 */
13
14/**
15 * A Horde_Cache implementation for caching IMAP/POP data.
16 * Requires the Horde_Cache package.
17 *
18 * @author    Michael Slusarz <slusarz@horde.org>
19 * @category  Horde
20 * @copyright 2005-2017 Horde LLC
21 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
22 * @package   Imap_Client
23 */
24class Horde_Imap_Client_Cache_Backend_Cache
25extends Horde_Imap_Client_Cache_Backend
26{
27    /** Cache structure version. */
28    const VERSION = 3;
29
30    /**
31     * The cache object.
32     *
33     * @var Horde_Cache
34     */
35    protected $_cache;
36
37    /**
38     * The working data for the current pageload.  All changes take place to
39     * this data.
40     *
41     * @var array
42     */
43    protected $_data = array();
44
45    /**
46     * The list of cache slices loaded.
47     *
48     * @var array
49     */
50    protected $_loaded = array();
51
52    /**
53     * The mapping of UIDs to slices.
54     *
55     * @var array
56     */
57    protected $_slicemap = array();
58
59    /**
60     * The list of items to update:
61     *   - add: (array) List of IDs that were added.
62     *   - slice: (array) List of slices that were modified.
63     *   - slicemap: (boolean) Was slicemap info changed?
64     *
65     * @var array
66     */
67    protected $_update = array();
68
69    /**
70     * Constructor.
71     *
72     * @param array $params  Configuration parameters:
73     * <pre>
74     *   - REQUIRED Parameters:
75     *     - cacheob: (Horde_Cache) The cache object to use.
76     *
77     *   - Optional Parameters:
78     *     - lifetime: (integer) The lifetime of the cache data (in seconds).
79     *                 DEFAULT: 1 week (604800 seconds)
80     *     - slicesize: (integer) The slicesize to use.
81     *                  DEFAULT: 50
82     * </pre>
83     */
84    public function __construct(array $params = array())
85    {
86        // Default parameters.
87        $params = array_merge(array(
88            'lifetime' => 604800,
89            'slicesize' => 50
90        ), array_filter($params));
91
92        if (!isset($params['cacheob'])) {
93            throw new InvalidArgumentException('Missing cacheob parameter.');
94        }
95
96        foreach (array('lifetime', 'slicesize') as $val) {
97            $params[$val] = intval($params[$val]);
98        }
99
100        parent::__construct($params);
101    }
102
103    /**
104     * Initialization tasks.
105     */
106    protected function _initOb()
107    {
108        $this->_cache = $this->_params['cacheob'];
109        register_shutdown_function(array($this, 'save'));
110    }
111
112    /**
113     * Updates the cache.
114     */
115    public function save()
116    {
117        $lifetime = $this->_params['lifetime'];
118
119        foreach ($this->_update as $mbox => $val) {
120            $s = &$this->_slicemap[$mbox];
121
122            try {
123                if (!empty($val['add'])) {
124                    if ($s['c'] <= $this->_params['slicesize']) {
125                        $val['slice'][] = $s['i'];
126                        $this->_loadSlice($mbox, $s['i']);
127                    }
128                    $val['slicemap'] = true;
129
130                    foreach (array_keys(array_flip($val['add'])) as $uid) {
131                        if ($s['c']++ > $this->_params['slicesize']) {
132                            $s['c'] = 0;
133                            $val['slice'][] = ++$s['i'];
134                            $this->_loadSlice($mbox, $s['i']);
135                        }
136                        $s['s'][$uid] = $s['i'];
137                    }
138                }
139
140                if (!empty($val['slice'])) {
141                    $d = &$this->_data[$mbox];
142                    $val['slicemap'] = true;
143
144                    foreach (array_keys(array_flip($val['slice'])) as $slice) {
145                        $data = array();
146                        foreach (array_keys($s['s'], $slice) as $uid) {
147                            $data[$uid] = is_array($d[$uid])
148                                ? serialize($d[$uid])
149                                : $d[$uid];
150                        }
151                        $this->_cache->set($this->_getCid($mbox, $slice), serialize($data), $lifetime);
152                    }
153                }
154
155                if (!empty($val['slicemap'])) {
156                    $this->_cache->set($this->_getCid($mbox, 'slicemap'), serialize($s), $lifetime);
157                }
158            } catch (Horde_Exception $e) {
159            }
160        }
161
162        $this->_update = array();
163    }
164
165    /**
166     */
167    public function get($mailbox, $uids, $fields, $uidvalid)
168    {
169        $ret = array();
170        $this->_loadUids($mailbox, $uids, $uidvalid);
171
172        if (empty($this->_data[$mailbox])) {
173            return $ret;
174        }
175
176        if (!empty($fields)) {
177            $fields = array_flip($fields);
178        }
179        $ptr = &$this->_data[$mailbox];
180
181        foreach (array_intersect($uids, array_keys($ptr)) as $val) {
182            if (is_string($ptr[$val])) {
183                try {
184                    $ptr[$val] = @unserialize($ptr[$val]);
185                } catch (Exception $e) {}
186            }
187
188            $ret[$val] = (empty($fields) || empty($ptr[$val]))
189                ? $ptr[$val]
190                : array_intersect_key($ptr[$val], $fields);
191        }
192
193        return $ret;
194    }
195
196    /**
197     */
198    public function getCachedUids($mailbox, $uidvalid)
199    {
200        $this->_loadSliceMap($mailbox, $uidvalid);
201        return array_unique(array_merge(
202            array_keys($this->_slicemap[$mailbox]['s']),
203            (isset($this->_update[$mailbox]) ? $this->_update[$mailbox]['add'] : array())
204        ));
205    }
206
207    /**
208     */
209    public function set($mailbox, $data, $uidvalid)
210    {
211        $update = array_keys($data);
212
213        try {
214            $this->_loadUids($mailbox, $update, $uidvalid);
215        } catch (Horde_Imap_Client_Exception $e) {
216            // Ignore invalidity - just start building the new cache
217        }
218
219        $d = &$this->_data[$mailbox];
220        $s = &$this->_slicemap[$mailbox]['s'];
221        $add = $updated = array();
222
223        foreach ($data as $k => $v) {
224            if (isset($d[$k])) {
225                if (is_string($d[$k])) {
226                    try {
227                        $d[$k] = @unserialize($d[$k]);
228                    } catch (Exception $e) {}
229                }
230                $d[$k] = is_array($d[$k])
231                    ? array_merge($d[$k], $v)
232                    : $v;
233                if (isset($s[$k])) {
234                    $updated[$s[$k]] = true;
235                }
236            } else {
237                $d[$k] = $v;
238                $add[] = $k;
239            }
240        }
241
242        $this->_toUpdate($mailbox, 'add', $add);
243        $this->_toUpdate($mailbox, 'slice', array_keys($updated));
244    }
245
246    /**
247     */
248    public function getMetaData($mailbox, $uidvalid, $entries)
249    {
250        $this->_loadSliceMap($mailbox, $uidvalid);
251
252        return empty($entries)
253            ? $this->_slicemap[$mailbox]['d']
254            : array_intersect_key($this->_slicemap[$mailbox]['d'], array_flip($entries));
255    }
256
257    /**
258     */
259    public function setMetaData($mailbox, $data)
260    {
261        $this->_loadSliceMap($mailbox, isset($data['uidvalid']) ? $data['uidvalid'] : null);
262        $this->_slicemap[$mailbox]['d'] = array_merge($this->_slicemap[$mailbox]['d'], $data);
263        $this->_toUpdate($mailbox, 'slicemap', true);
264    }
265
266    /**
267     */
268    public function deleteMsgs($mailbox, $uids)
269    {
270        if (empty($uids)) {
271            return;
272        }
273
274        $this->_loadSliceMap($mailbox);
275
276        $slicemap = &$this->_slicemap[$mailbox];
277        $deleted = array_intersect_key($slicemap['s'], array_flip($uids));
278
279        if (isset($this->_update[$mailbox])) {
280            $this->_update[$mailbox]['add'] = array_diff(
281                $this->_update[$mailbox]['add'],
282                $uids
283            );
284        }
285
286        if (empty($deleted)) {
287            return;
288        }
289
290        $this->_loadUids($mailbox, array_keys($deleted));
291        $d = &$this->_data[$mailbox];
292
293        foreach (array_keys($deleted) as $id) {
294            unset($d[$id], $slicemap['s'][$id]);
295        }
296
297        foreach (array_unique($deleted) as $slice) {
298            /* Get rid of slice if less than 10% of capacity. */
299            if (($slice != $slicemap['i']) &&
300                ($slice_uids = array_keys($slicemap['s'], $slice)) &&
301                ($this->_params['slicesize'] * 0.1) > count($slice_uids)) {
302                $this->_toUpdate($mailbox, 'add', $slice_uids);
303                $this->_cache->expire($this->_getCid($mailbox, $slice));
304                foreach ($slice_uids as $val) {
305                    unset($slicemap['s'][$val]);
306                }
307            } else {
308                $this->_toUpdate($mailbox, 'slice', array($slice));
309            }
310        }
311    }
312
313    /**
314     */
315    public function deleteMailbox($mailbox)
316    {
317        $this->_loadSliceMap($mailbox);
318        $this->_deleteMailbox($mailbox);
319    }
320
321    /**
322     */
323    public function clear($lifetime)
324    {
325        $this->_cache->clear();
326        $this->_data = $this->_loaded = $this->_slicemap = $this->_update = array();
327    }
328
329    /**
330     * Create the unique ID used to store the data in the cache.
331     *
332     * @param string $mailbox  The mailbox to cache.
333     * @param string $slice    The cache slice.
334     *
335     * @return string  The cache ID.
336     */
337    protected function _getCid($mailbox, $slice)
338    {
339        return implode('|', array(
340            'horde_imap_client',
341            $this->_params['username'],
342            $mailbox,
343            $this->_params['hostspec'],
344            $this->_params['port'],
345            $slice,
346            self::VERSION
347        ));
348    }
349
350    /**
351     * Delete a mailbox from the cache.
352     *
353     * @param string $mbox  The mailbox to delete.
354     */
355    protected function _deleteMailbox($mbox)
356    {
357        foreach (array_merge(array_keys(array_flip($this->_slicemap[$mbox]['s'])), array('slicemap')) as $slice) {
358            $cid = $this->_getCid($mbox, $slice);
359            $this->_cache->expire($cid);
360            unset($this->_loaded[$cid]);
361        }
362
363        unset(
364            $this->_data[$mbox],
365            $this->_slicemap[$mbox],
366            $this->_update[$mbox]
367        );
368    }
369
370    /**
371     * Load UIDs by regenerating from the cache.
372     *
373     * @param string $mailbox    The mailbox to load.
374     * @param array $uids        The UIDs to load.
375     * @param integer $uidvalid  The IMAP uidvalidity value of the mailbox.
376     */
377    protected function _loadUids($mailbox, $uids, $uidvalid = null)
378    {
379        if (!isset($this->_data[$mailbox])) {
380            $this->_data[$mailbox] = array();
381        }
382
383        $this->_loadSliceMap($mailbox, $uidvalid);
384
385        if (!empty($uids)) {
386            foreach (array_unique(array_intersect_key($this->_slicemap[$mailbox]['s'], array_flip($uids))) as $slice) {
387                $this->_loadSlice($mailbox, $slice);
388            }
389        }
390    }
391
392    /**
393     * Load UIDs from a cache slice.
394     *
395     * @param string $mailbox  The mailbox to load.
396     * @param integer $slice   The slice to load.
397     */
398    protected function _loadSlice($mailbox, $slice)
399    {
400        $cache_id = $this->_getCid($mailbox, $slice);
401
402        if (!empty($this->_loaded[$cache_id])) {
403            return;
404        }
405
406        if (($data = $this->_cache->get($cache_id, 0)) !== false) {
407            try {
408                $data = @unserialize($data);
409            } catch (Exception $e) {}
410        }
411
412        if (($data !== false) && is_array($data)) {
413            $this->_data[$mailbox] += $data;
414            $this->_loaded[$cache_id] = true;
415        } else {
416            $ptr = &$this->_slicemap[$mailbox];
417
418            // Slice data is corrupt; remove from slicemap.
419            foreach (array_keys($ptr['s'], $slice) as $val) {
420                unset($ptr['s'][$val]);
421            }
422
423            if ($slice == $ptr['i']) {
424                $ptr['c'] = 0;
425            }
426        }
427    }
428
429    /**
430     * Load the slicemap for a given mailbox.  The slicemap contains
431     * the uidvalidity information, the UIDs->slice lookup table, and any
432     * metadata that needs to be saved for the mailbox.
433     *
434     * @param string $mailbox    The mailbox.
435     * @param integer $uidvalid  The IMAP uidvalidity value of the mailbox.
436     */
437    protected function _loadSliceMap($mailbox, $uidvalid = null)
438    {
439        if (!isset($this->_slicemap[$mailbox]) &&
440            (($data = $this->_cache->get($this->_getCid($mailbox, 'slicemap'), 0)) !== false)) {
441            try {
442                if (($slice = @unserialize($data)) &&
443                    is_array($slice)) {
444                    $this->_slicemap[$mailbox] = $slice;
445                }
446            } catch (Exception $e) {}
447        }
448
449        if (isset($this->_slicemap[$mailbox])) {
450            $ptr = &$this->_slicemap[$mailbox];
451            if (is_null($ptr['d']['uidvalid'])) {
452                $ptr['d']['uidvalid'] = $uidvalid;
453                return;
454            } elseif (!is_null($uidvalid) &&
455                      ($ptr['d']['uidvalid'] != $uidvalid)) {
456                $this->_deleteMailbox($mailbox);
457            } else {
458                return;
459            }
460        }
461
462        $this->_slicemap[$mailbox] = array(
463            // Tracking count for purposes of determining slices
464            'c' => 0,
465            // Metadata storage
466            // By default includes UIDVALIDITY of mailbox.
467            'd' => array('uidvalid' => $uidvalid),
468            // The ID of the last slice.
469            'i' => 0,
470            // The slice list.
471            's' => array()
472        );
473    }
474
475    /**
476     * Add update entry for a mailbox.
477     *
478     * @param string $mailbox  The mailbox.
479     * @param string $type     'add', 'slice', or 'slicemap'.
480     * @param mixed $data      The data to update.
481     */
482    protected function _toUpdate($mailbox, $type, $data)
483    {
484        if (!isset($this->_update[$mailbox])) {
485            $this->_update[$mailbox] = array(
486                'add' => array(),
487                'slice' => array()
488            );
489        }
490
491        $this->_update[$mailbox][$type] = ($type == 'slicemap')
492            ? $data
493            : array_merge($this->_update[$mailbox][$type], $data);
494    }
495
496    /* Serializable methods. */
497
498    /**
499     */
500    public function serialize()
501    {
502        $this->save();
503        return parent::serialize();
504    }
505
506}
507