1<?php
2
3/**
4 +-----------------------------------------------------------------------+
5 | This file is part of the Roundcube Webmail client                     |
6 |                                                                       |
7 | Copyright (C) The Roundcube Dev Team                                  |
8 | Copyright (C) Kolab Systems AG                                        |
9 |                                                                       |
10 | Licensed under the GNU General Public License version 3 or            |
11 | any later version with exceptions for skins & plugins.                |
12 | See the README file for a full license statement.                     |
13 |                                                                       |
14 | PURPOSE:                                                              |
15 |   Caching engine                                                      |
16 +-----------------------------------------------------------------------+
17 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
18 | Author: Aleksander Machniak <alec@alec.pl>                            |
19 +-----------------------------------------------------------------------+
20*/
21
22/**
23 * Interface class for accessing Roundcube cache
24 *
25 * @package    Framework
26 * @subpackage Cache
27 */
28class rcube_cache
29{
30    protected $type;
31    protected $userid;
32    protected $prefix;
33    protected $ttl;
34    protected $packed;
35    protected $indexed;
36    protected $index;
37    protected $index_update;
38    protected $cache        = [];
39    protected $updates      = [];
40    protected $exp_records  = [];
41    protected $refresh_time = 0.5; // how often to refresh/save the index and cache entries
42    protected $debug        = false;
43    protected $max_packet   = -1;
44
45    const MAX_EXP_LEVEL     = 2;
46    const DATE_FORMAT       = 'Y-m-d H:i:s.u';
47    const DATE_FORMAT_REGEX = '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{1,6}';
48
49
50    /**
51     * Object factory
52     *
53     * @param string $type    Engine type ('db', 'memcache', 'apc', 'redis')
54     * @param int    $userid  User identifier
55     * @param string $prefix  Key name prefix
56     * @param string $ttl     Expiration time of memcache/apc items
57     * @param bool   $packed  Enables/disabled data serialization.
58     *                        It's possible to disable data serialization if you're sure
59     *                        stored data will be always a safe string
60     * @param bool   $indexed Use indexed cache. Indexed cache is more appropriate for
61     *                        storing big data with possibility to remove it by a key prefix.
62     *                        Non-indexed cache does not remove data, but flags it for expiration,
63     *                        also stores it in memory until close() method is called.
64     *
65     * @param rcube_cache Cache object
66     */
67    public static function factory($type, $userid, $prefix = '', $ttl = 0, $packed = true, $indexed = false)
68    {
69        $driver = strtolower($type) ?: 'db';
70        $class  = "rcube_cache_$driver";
71
72        if (!$driver || !class_exists($class)) {
73            rcube::raise_error([
74                    'code' => 600, 'type' => 'db',
75                    'line' => __LINE__, 'file' => __FILE__,
76                    'message' => "Configuration error. Unsupported cache driver: $driver"
77                ],
78                true, true
79            );
80        }
81
82        return new $class($userid, $prefix, $ttl, $packed, $indexed);
83    }
84
85    /**
86     * Object constructor.
87     *
88     * @param int    $userid  User identifier
89     * @param string $prefix  Key name prefix
90     * @param string $ttl     Expiration time of memcache/apc items
91     * @param bool   $packed  Enables/disabled data serialization.
92     *                        It's possible to disable data serialization if you're sure
93     *                        stored data will be always a safe string
94     * @param bool   $indexed Use indexed cache. Indexed cache is more appropriate for
95     *                        storing big data with possibility to remove it by key prefix.
96     *                        Non-indexed cache does not remove data, but flags it for expiration,
97     *                        also stores it in memory until close() method is called.
98     */
99    public function __construct($userid, $prefix = '', $ttl = 0, $packed = true, $indexed = false)
100    {
101        $this->userid  = (int) $userid;
102        $this->ttl     = min(get_offset_sec($ttl), 2592000);
103        $this->prefix  = $prefix;
104        $this->packed  = $packed;
105        $this->indexed = $indexed;
106    }
107
108    /**
109     * Returns cached value.
110     *
111     * @param string $key Cache key name
112     *
113     * @return mixed Cached value
114     */
115    public function get($key)
116    {
117        if (array_key_exists($key, $this->cache)) {
118            return $this->cache[$key];
119        }
120
121        return $this->read_record($key);
122    }
123
124    /**
125     * Sets (add/update) value in cache.
126     *
127     * @param string $key  Cache key name
128     * @param mixed  $data Cache data
129     *
130     * @return bool True on success, False on failure
131     */
132    public function set($key, $data)
133    {
134        return $this->write_record($key, $data);
135    }
136
137    /**
138     * @deprecated Use self::get()
139     */
140    public function read($key)
141    {
142        return $this->get($key);
143    }
144
145    /**
146     * @deprecated Use self::set()
147     */
148    public function write($key, $data)
149    {
150        return $this->set($key, $data);
151    }
152
153    /**
154     * Clears the cache.
155     *
156     * @param string $key         Cache key name or pattern
157     * @param bool   $prefix_mode Enable it to clear all keys starting
158     *                            with prefix specified in $key
159     */
160    public function remove($key = null, $prefix_mode = false)
161    {
162        // Remove record(s) from the backend
163        $this->remove_record($key, $prefix_mode);
164    }
165
166    /**
167     * Remove cache records older than ttl
168     */
169    public function expunge()
170    {
171        // to be overwritten by engine class
172    }
173
174    /**
175     * Remove expired records of all caches
176     */
177    public static function gc()
178    {
179        // Only DB cache requires an action to remove expired entries
180        rcube_cache_db::gc();
181    }
182
183    /**
184     * Writes the cache back to the DB.
185     */
186    public function close()
187    {
188        $this->write_index(true);
189        $this->index   = null;
190        $this->cache   = [];
191        $this->updates = [];
192    }
193
194    /**
195     * A helper to build cache key for specified parameters.
196     *
197     * @param string $prefix Key prefix (Max. length 64 characters)
198     * @param array  $params Additional parameters
199     *
200     * @return string Key name
201     */
202    public static function key_name($prefix, $params = [])
203    {
204        $cache_key = $prefix;
205
206        if (!empty($params)) {
207            $func = function($v) {
208                if (is_array($v)) {
209                    sort($v);
210                }
211                return is_string($v) ? $v : serialize($v);
212            };
213
214            $params = array_map($func, $params);
215            $cache_key .= '.' . md5(implode(':', $params));
216        }
217
218        return $cache_key;
219    }
220
221    /**
222     * Reads cache entry.
223     *
224     * @param string $key Cache key name
225     *
226     * @return mixed Cached value
227     */
228    protected function read_record($key)
229    {
230        $this->load_index();
231
232        // Consistency check (#1490390)
233        if (is_array($this->index) && !in_array($key, $this->index)) {
234            // we always check if the key exist in the index
235            // to have data in consistent state. Keeping the index consistent
236            // is needed for keys delete operation when we delete all keys or by prefix.
237            return;
238        }
239
240        $ckey = $this->ckey($key);
241        $data = $this->get_item($ckey);
242
243        if ($this->indexed) {
244            return $data !== false ? $this->unserialize($data) : null;
245        }
246
247        if ($data !== false) {
248            $timestamp = 0;
249            $utc       = new DateTimeZone('UTC');
250
251            // Extract timestamp from the data entry
252            if (preg_match('/^(' . self::DATE_FORMAT_REGEX . '):/', $data, $matches)) {
253                try {
254                    $timestamp = new DateTime($matches[1], $utc);
255                    $data      = substr($data, strlen($matches[1]) + 1);
256                }
257                catch (Exception $e) {
258                    // invalid date = no timestamp
259                }
260            }
261
262            // Check if the entry is still valid by comparing with EXP timestamps
263            // For example for key 'mailboxes.123456789' we check entries:
264            // 'EXP:*', 'EXP:mailboxes' and 'EXP:mailboxes.123456789'.
265            if ($timestamp) {
266                $path     = explode('.', "*.$key");
267                $path_len = min(self::MAX_EXP_LEVEL + 1, count($path));
268
269                for ($x = 1; $x <= $path_len; $x++) {
270                    $prefix = implode('.', array_slice($path, 0, $x));
271                    if ($x > 1) {
272                        $prefix = substr($prefix, 2); // remove "*." prefix
273                    }
274
275                    if (($ts = $this->get_exp_timestamp($prefix)) && $ts > $timestamp) {
276                        $timestamp = 0;
277                        break;
278                    }
279                }
280            }
281
282            $data = $timestamp ? $this->unserialize($data) : null;
283        }
284        else {
285            $data = null;
286        }
287
288        return $this->cache[$key] = $data;
289    }
290
291    /**
292     * Writes single cache record into DB.
293     *
294     * @param string $key  Cache key name
295     * @param mixed  $data Serialized cache data
296     *
297     * @return bool True on success, False on failure
298     */
299    protected function write_record($key, $data)
300    {
301        if ($this->indexed) {
302            $result = $this->store_record($key, $data);
303
304            if ($result) {
305                $this->load_index();
306                $this->index[] = $key;
307
308                if (!$this->index_update) {
309                    $this->index_update = time();
310                }
311            }
312        }
313        else {
314            // In this mode we do not save the entry to the database immediately
315            // It's because we have cases where the same entry is updated
316            // multiple times in one request (e.g. 'messagecount' entry rcube_imap).
317            $this->updates[$key] = new DateTime('now', new DateTimeZone('UTC'));
318            $this->cache[$key]   = $data;
319            $result = true;
320        }
321
322        $this->write_index();
323
324        return $result;
325    }
326
327    /**
328     * Deletes the cache record(s).
329     *
330     * @param string  $key         Cache key name or pattern
331     * @param boolean $prefix_mode Enable it to clear all keys starting
332     *                             with prefix specified in $key
333     */
334    protected function remove_record($key = null, $prefix_mode = false)
335    {
336        if ($this->indexed) {
337            return $this->remove_record_indexed($key, $prefix_mode);
338        }
339
340        // "Remove" all keys
341        if ($key === null) {
342            $ts = new DateTime('now', new DateTimeZone('UTC'));
343            $this->add_item($this->ekey('*'), $ts->format(self::DATE_FORMAT));
344            $this->cache = [];
345        }
346        // "Remove" keys by name prefix
347        else if ($prefix_mode) {
348            $ts     = new DateTime('now', new DateTimeZone('UTC'));
349            $prefix = implode('.', array_slice(explode('.', trim($key, '. ')), 0, self::MAX_EXP_LEVEL));
350
351            $this->add_item($this->ekey($prefix), $ts->format(self::DATE_FORMAT));
352
353            foreach (array_keys($this->cache) as $k) {
354                if (strpos($k, $key) === 0) {
355                    $this->cache[$k] = null;
356                }
357            }
358        }
359        // Remove one key by name
360        else {
361            $this->delete_item($this->ckey($key));
362            $this->cache[$key] = null;
363        }
364    }
365
366    /**
367     * @see self::remove_record()
368     */
369    protected function remove_record_indexed($key = null, $prefix_mode = false)
370    {
371        $this->load_index();
372
373        // Remove all keys
374        if ($key === null) {
375            foreach ($this->index as $key) {
376                $this->delete_item($this->ckey($key));
377                if (!$this->index_update) {
378                    $this->index_update = time();
379                }
380            }
381
382            $this->index = [];
383        }
384        // Remove keys by name prefix
385        else if ($prefix_mode) {
386            foreach ($this->index as $idx => $k) {
387                if (strpos($k, $key) === 0) {
388                    $this->delete_item($this->ckey($k));
389                    unset($this->index[$idx]);
390                    if (!$this->index_update) {
391                        $this->index_update = time();
392                    }
393                }
394            }
395        }
396        // Remove one key by name
397        else {
398            $this->delete_item($this->ckey($key));
399            if (($idx = array_search($key, $this->index)) !== false) {
400                unset($this->index[$idx]);
401                if (!$this->index_update) {
402                    $this->index_update = time();
403                }
404            }
405        }
406
407        $this->write_index();
408    }
409
410    /**
411     * Writes the index entry as well as updated entries into memcache/apc/redis DB.
412     */
413    protected function write_index($force = null)
414    {
415        // Write updated/new entries when needed
416        if (!$this->indexed) {
417            $need_update = $force === true;
418
419            if (!$need_update && !empty($this->updates)) {
420                $now         = new DateTime('now', new DateTimeZone('UTC'));
421                $need_update = floatval(min($this->updates)->format('U.u')) < floatval($now->format('U.u')) - $this->refresh_time;
422            }
423
424            if ($need_update) {
425                foreach ($this->updates as $key => $ts) {
426                    if (isset($this->cache[$key])) {
427                        $this->store_record($key, $this->cache[$key], $ts);
428                    }
429                }
430
431                $this->updates = [];
432            }
433        }
434        // Write index entry when needed
435        else {
436            $need_update = $this->index_update && $this->index !== null
437                && ($force === true || $this->index_update > time() - $this->refresh_time);
438
439            if ($need_update) {
440                $index = serialize(array_values(array_unique($this->index)));
441
442                $this->add_item($this->ikey(), $index);
443                $this->index_update = null;
444                $this->index        = null;
445            }
446        }
447    }
448
449    /**
450     * Gets the index entry from memcache/apc/redis DB.
451     */
452    protected function load_index()
453    {
454        if (!$this->indexed) {
455            return;
456        }
457
458        if ($this->index !== null) {
459            return;
460        }
461
462        $data        = $this->get_item($this->ikey());
463        $this->index = $data ? unserialize($data) : [];
464    }
465
466    /**
467     * Write data entry into cache
468     */
469    protected function store_record($key, $data, $ts = null)
470    {
471        $value = $this->serialize($data);
472
473        if (!$this->indexed) {
474            if (!$ts) {
475                $ts = new DateTime('now', new DateTimeZone('UTC'));
476            }
477
478            $value = $ts->format(self::DATE_FORMAT) . ':' . $value;
479        }
480
481        $size = strlen($value);
482
483        // don't attempt to write too big data sets
484        if ($size > $this->max_packet_size()) {
485            trigger_error("rcube_cache: max_packet_size ($this->max_packet) exceeded for key $key. Tried to write $size bytes", E_USER_WARNING);
486            return false;
487        }
488
489        return $this->add_item($this->ckey($key), $value);
490    }
491
492    /**
493     * Fetches cache entry.
494     *
495     * @param string $key Cache internal key name
496     *
497     * @return mixed Cached value
498     */
499    protected function get_item($key)
500    {
501        // to be overwritten by engine class
502    }
503
504    /**
505     * Adds entry into memcache/apc/redis DB.
506     *
507     * @param string $key  Cache internal key name
508     * @param mixed  $data Serialized cache data
509     *
510     * @param bool True on success, False on failure
511     */
512    protected function add_item($key, $data)
513    {
514        // to be overwritten by engine class
515    }
516
517    /**
518     * Deletes entry from memcache/apc/redis DB.
519     *
520     * @param string $key Cache internal key name
521     *
522     * @param bool True on success, False on failure
523     */
524    protected function delete_item($key)
525    {
526        // to be overwritten by engine class
527    }
528
529    /**
530     * Get EXP:<key> record value from cache
531     */
532    protected function get_exp_timestamp($key)
533    {
534        if (!array_key_exists($key, $this->exp_records)) {
535            $data = $this->get_item($this->ekey($key));
536
537            $this->exp_records[$key] = $data ? new DateTime($data, new DateTimeZone('UTC')) : null;
538        }
539
540        return $this->exp_records[$key];
541    }
542
543    /**
544     * Creates per-user index cache key name (for memcache, apc, redis)
545     *
546     * @return string Cache key
547     */
548    protected function ikey()
549    {
550        $key = $this->prefix . 'INDEX';
551
552        if ($this->userid) {
553            $key = $this->userid . ':' . $key;
554        }
555
556        return $key;
557    }
558
559    /**
560     * Creates per-user cache key name (for memcache, apc, redis)
561     *
562     * @param string $key Cache key name
563     *
564     * @return string Cache key
565     */
566    protected function ckey($key)
567    {
568        $key = $this->prefix . ':' . $key;
569
570        if ($this->userid) {
571            $key = $this->userid . ':' . $key;
572        }
573
574        return $key;
575    }
576
577    /**
578     * Creates per-user cache key name for expiration time entry
579     *
580     * @param string $key Cache key name
581     *
582     * @return string Cache key
583     */
584    protected function ekey($key, $prefix = null)
585    {
586        $key = $this->prefix . 'EXP:' . $key;
587
588        if ($this->userid) {
589            $key = $this->userid . ':' . $key;
590        }
591
592        return $key;
593    }
594
595    /**
596     * Serializes data for storing
597     */
598    protected function serialize($data)
599    {
600        return $this->packed ? serialize($data) : $data;
601    }
602
603    /**
604     * Unserializes serialized data
605     */
606    protected function unserialize($data)
607    {
608        return $this->packed ? @unserialize($data) : $data;
609    }
610
611    /**
612     * Determine the maximum size for cache data to be written
613     */
614    protected function max_packet_size()
615    {
616        if ($this->max_packet < 0) {
617            $config           = rcube::get_instance()->config;
618            $max_packet       = $config->get($this->type . '_max_allowed_packet');
619            $this->max_packet = parse_bytes($max_packet) ?: 2097152; // default/max is 2 MB
620        }
621
622        return $this->max_packet;
623    }
624
625    /**
626     * Write memcache/apc/redis debug info to the log
627     */
628    protected function debug($type, $key, $data = null, $result = null)
629    {
630        $line = strtoupper($type) . ' ' . $key;
631
632        if ($data !== null) {
633            $line .= ' ' . ($this->packed ? $data : serialize($data));
634        }
635
636        rcube::debug($this->type, $line, $result);
637    }
638}
639