source: trunk/lib/Version.inc.php @ 550

Last change on this file since 550 was 550, checked in by anonymous, 8 years ago

Escaped quotes from email from names.
Changed logMsg string truncation method and added version to email log msg.
Better variable testing in carry queries.
Spelling errors.
Added runtime cache to Currency.
Added logging to form validation.
More robust form validation.
Added json serialization methond to Version.

File size: 21.4 KB
Line 
1<?php
2/**
3 * The Strangecode Codebase - a general application development framework for PHP
4 * For details visit the project site: <http://trac.strangecode.com/codebase/>
5 * Copyright 2001-2012 Strangecode, LLC
6 *
7 * This file is part of The Strangecode Codebase.
8 *
9 * The Strangecode Codebase is free software: you can redistribute it and/or
10 * modify it under the terms of the GNU General Public License as published by the
11 * Free Software Foundation, either version 3 of the License, or (at your option)
12 * any later version.
13 *
14 * The Strangecode Codebase is distributed in the hope that it will be useful, but
15 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 * details.
18 *
19 * You should have received a copy of the GNU General Public License along with
20 * The Strangecode Codebase. If not, see <http://www.gnu.org/licenses/>.
21 */
22
23/**
24 * Version.inc.php
25 *
26 * The Version class provides a system for saving, reviewing, and
27 * restoring versions of a record of any DB table. All the data in the record is
28 * serialized, compressed, and saved in a blob in the version_tbl. Restoring a
29 * version simply does a REPLACE INTO of the data. It is very simple, and works
30 * with multiple database tables, but the drawback is that relationships for
31 * a record cannot be retained. For example, an article from an article_tbl can
32 * be saved, but not categories associated to the record in a category_article_tbl.
33 * The restored article will simple retain the relationships that the previous
34 * current article had.
35 *
36 * @author  Quinn Comendant <quinn@strangecode.com>
37 * @version 2.1
38 */
39class Version
40{
41
42    // A place to keep an object instance for the singleton pattern.
43    protected static $instance = null;
44
45    // Configuration of this object.
46    protected $_params = array(
47        'max_qty' => 100, // Never have more than this many versions of each record.
48        'min_qty' => 25, // Keep at least this many versions of each record.
49        'min_days' => 7, // Keep ALL versions within this many days, even if MORE than min_qty.
50        'db_table' => 'version_tbl',
51
52        // Automatically create table and verify columns. Better set to false after site launch.
53        // This value is overwritten by the $app->getParam('db_create_tables') setting if it is available.
54        'create_table' => true,
55
56        // If true, makes an exact comparison of saved vs. live table schemas. If false, just checks that the saved columns are available.
57        'db_schema_strict' => true,
58
59        // Serialization method.
60        // Legacy installations will have been using 'phpserialize' but these should migrate to use 'json' to avoid PHP object injection https://www.owasp.org/index.php/PHP_Object_Injection
61        'serialization_method' => 'phpserialize', // Or 'json'
62    );
63
64    // Auth_SQL object from which to access a current user_id.
65    protected $_auth;
66
67    /**
68     * This method enforces the singleton pattern for this class.
69     *
70     * @return  object  Reference to the global Lock object.
71     * @access  public
72     * @static
73     */
74    public static function &getInstance($auth_object=null)
75    {
76        if (self::$instance === null) {
77            self::$instance = new self($auth_object);
78        }
79
80        return self::$instance;
81    }
82
83    /**
84     * Constructor. Pass an Auth object on which to perform user lookups.
85     *
86     * @param mixed  $auth_object  An Auth_SQL object.
87     */
88    public function __construct($auth_object=null)
89    {
90        $app =& App::getInstance();
91
92        if (!is_null($auth_object) || is_null($this->_auth)) {
93            if (!method_exists($auth_object, 'get') || !method_exists($auth_object, 'getUsername')) {
94                trigger_error('Constructor not provided a valid Auth_* object.', E_USER_ERROR);
95            }
96
97            $this->_auth = $auth_object;
98        }
99
100        // Get create tables config from global context.
101        if (!is_null($app->getParam('db_create_tables'))) {
102            $this->setParam(array('create_table' => $app->getParam('db_create_tables')));
103        }
104    }
105
106    /**
107     * Setup the database table for this class.
108     *
109     * @access  public
110     * @author  Quinn Comendant <quinn@strangecode.com>
111     * @since   26 Aug 2005 17:09:36
112     */
113    public function initDB($recreate_db=false)
114    {
115        $app =& App::getInstance();
116        $db =& DB::getInstance();
117
118        static $_db_tested = false;
119
120        if ($recreate_db || !$_db_tested && $this->getParam('create_table')) {
121            if ($recreate_db) {
122                $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_table'));
123                $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_table')), LOG_INFO, __FILE__, __LINE__);
124            }
125            $db->query("CREATE TABLE IF NOT EXISTS " . $db->escapeString($this->getParam('db_table')) . " (
126                version_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
127                record_table VARCHAR(255) NOT NULL DEFAULT '',
128                record_key VARCHAR(255) NOT NULL DEFAULT '',
129                record_val VARCHAR(255) NOT NULL DEFAULT '',
130                version_data MEDIUMBLOB NOT NULL,
131                version_title VARCHAR(255) NOT NULL DEFAULT '',
132                version_number SMALLINT(11) UNSIGNED NOT NULL DEFAULT '0',
133                version_notes VARCHAR(255) NOT NULL DEFAULT '',
134                saved_by_user_id SMALLINT(11) NOT NULL DEFAULT '0',
135                version_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
136                KEY record_table (record_table),
137                KEY record_key (record_key),
138                KEY record_val (record_val)
139            )");
140
141            if (!$db->columnExists($this->getParam('db_table'), array(
142                'version_id',
143                'record_table',
144                'record_key',
145                'record_val',
146                'version_data',
147                'version_title',
148                'version_number',
149                'version_notes',
150                'saved_by_user_id',
151                'version_datetime',
152            ), false, false)) {
153                $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), LOG_ALERT, __FILE__, __LINE__);
154                trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), E_USER_ERROR);
155            }
156        }
157        $_db_tested = true;
158    }
159
160    /**
161     * Set the params of this object.
162     *
163     * @param  array $params   Array of param keys and values to set.
164     */
165    public function setParam($params=null)
166    {
167        $app =& App::getInstance();
168
169        if (isset($params['serialization_method']) && !in_array($params['serialization_method'], ['phpserialize', 'json'])) {
170            trigger_error(sprintf('Invalid serialization_method: %s', $params['serialization_method']), E_USER_ERROR);
171        }
172        if (isset($params) && is_array($params)) {
173            // Merge new parameters with old overriding only those passed.
174            $this->_params = array_merge($this->_params, $params);
175        }
176    }
177
178    /**
179     * Return the value of a parameter, if it exists.
180     *
181     * @access public
182     * @param string $param        Which parameter to return.
183     * @return mixed               Configured parameter value.
184     */
185    public function getParam($param)
186    {
187        $app =& App::getInstance();
188
189        if (array_key_exists($param, $this->_params)) {
190            return $this->_params[$param];
191        } else {
192            $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
193            return null;
194        }
195    }
196
197    /**
198     * Saves a version of the current record into the version table.
199     *
200     * @param string $record_table  The table containing the record.
201     * @param string $record_key    The key column for the record.
202     * @param string $record_val    The value of the key column for the record.
203     * @param string $title         The title of this record. Only used for human presentation.
204     *
205     * @return int                  The id for the version (mysql last insert id).
206     */
207    public function create($record_table, $record_key, $record_val, $title='', $notes='')
208    {
209        $app =& App::getInstance();
210        $db =& DB::getInstance();
211
212        $this->initDB();
213
214        // Get current record.
215        if (!$record = $this->getCurrent($record_table, $record_key, $record_val)) {
216            $app->logMsg(sprintf('Could not create %s version, record not found: %s, %s, %s.', $title, $record_table, $record_key, $record_val), LOG_ERR, __FILE__, __LINE__);
217            return false;
218        }
219
220        // Get previous version_number.
221        $qid = $db->query("
222            SELECT MAX(version_number) FROM " . $db->escapeString($this->getParam('db_table')) . "
223            WHERE record_table = '" . $db->escapeString($record_table) . "'
224            AND record_key = '" . $db->escapeString($record_key) . "'
225            AND record_val = '" . $db->escapeString($record_val) . "'
226        ");
227        list($last_version_number) = mysql_fetch_row($qid);
228
229        // Clean-up old versions.
230        $this->deleteOld($record_table, $record_key, $record_val);
231
232        // Serialize the DB record.
233        switch ($this->getParam('serialization_method')) {
234        case 'phpserialize':
235            $data = gzcompress(serialize($record), 9);
236            break;
237
238        case 'json':
239            $data = gzcompress(json_encode($record), 9);
240            break;
241        }
242
243        // Save as new version.
244        // TODO: after MySQL 5.0.23 is released this query could benefit from INSERT DELAYED.
245        $db->query("
246            INSERT INTO " . $db->escapeString($this->getParam('db_table')) . " (
247                record_table,
248                record_key,
249                record_val,
250                version_data,
251                version_title,
252                version_number,
253                version_notes,
254                saved_by_user_id,
255                version_datetime
256            ) VALUES (
257                '" . $db->escapeString($record_table) . "',
258                '" . $db->escapeString($record_key) . "',
259                '" . $db->escapeString($record_val) . "',
260                '" . $db->escapeString($data) . "',
261                '" . $db->escapeString($title) . "',
262                '" . $db->escapeString($last_version_number + 1) . "',
263                '" . $db->escapeString($notes) . "',
264                '" . $db->escapeString($this->_auth->get('user_id')) . "',
265                NOW()
266            )
267        ");
268
269        return mysql_insert_id($db->getDBH());
270    }
271
272    /**
273     * Copy a version back into it's original table.
274     *
275     * @param string $version_id    The id of the version to restore.
276     *
277     * @return int                  The id for the version (mysql last insert id).
278     */
279    public function restore($version_id)
280    {
281        $app =& App::getInstance();
282        $db =& DB::getInstance();
283
284        $this->initDB();
285
286        // Get version data.
287        $qid = $db->query("
288            SELECT *
289            FROM " . $db->escapeString($this->getParam('db_table')) . "
290            WHERE version_id = '" . $db->escapeString($version_id) . "'
291        ");
292        if (!$record = mysql_fetch_assoc($qid)) {
293            $app->raiseMsg(sprintf(_("Version %s%s not found."), $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), MSG_WARNING, __FILE__, __LINE__);
294            $app->logMsg(sprintf('Version %s%s not found.', $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), LOG_WARNING, __FILE__, __LINE__);
295            return false;
296        }
297
298        // Unserialize the DB record.
299        switch ($this->getParam('serialization_method')) {
300        case 'phpserialize':
301            $data = unserialize(gzuncompress($record['version_data']));
302            break;
303
304        case 'json':
305            $data = json_decode(gzuncompress($record['version_data']), true);
306            break;
307        }
308
309        // Ensure saved db columns match current table schema.
310        if (!$db->columnExists($record['record_table'], array_keys($data), $this->getParam('db_schema_strict'))) {
311            $app->raiseMsg(sprintf(_("Version %s%s is not compatible with the current database table."), $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')')), MSG_ERR, __FILE__, __LINE__);
312            $app->logMsg(sprintf('Version %s%s restoration failed, DB schema does not match for table %s.', $version_id, (empty($record['version_title']) ? '' : ' (' . $record['version_title'] . ')'), $record['record_table']), LOG_ALERT, __FILE__, __LINE__);
313            return false;
314        }
315
316        // SQLize the keys of the specified versioned record.
317        $replace_keys = join(",\n", array_map(array($db, 'escapeString'), array_keys($data)));
318
319        // SQLize the keys of the values of the specified versioned record. (These are more complex because we need to account for SQL null values.)
320        $replace_values = '';
321        $comma = '';
322        foreach ($data as $v) {
323            $replace_values .= is_null($v) ? "$comma\nNULL" : "$comma\n'" . $db->escapeString($v) . "'";
324            $comma = ',';
325        }
326
327        // Disable foreign_key_checks to prevent ON DELETE triggers or restrictions.
328        $db->query("SET SESSION foreign_key_checks = 0");
329        // Replace current record with specified versioned record. Consider converting this SQL to use INSERT 
 ON DUPLICATE KEY UPDATE 

330        $db->query("
331        REPLACE INTO " . $record['record_table'] . " (
332                $replace_keys
333            ) VALUES (
334                $replace_values
335            );
336        ");
337        // Re-enable foreign_key_checks.
338        $db->query("SET SESSION foreign_key_checks = 1");
339
340        return $record;
341    }
342
343    /**
344     * Version garbage collection. Deletes versions older than min_days
345     * when quantity of versions exceeds min_qty. If quantity
346     * exceeds 100 within min_days, the oldest are deleted to bring the
347     * quantity back down to min_qty.
348     *
349     * @param string $record_table  The table containing the record.
350     * @param string $record_key    The key column for the record.
351     * @param string $record_val    The value of the key column for the record.
352     *
353     * @return mixed                Array of versions, or false if none.
354     */
355    public function deleteOld($record_table, $record_key, $record_val)
356    {
357        $db =& DB::getInstance();
358
359        $this->initDB();
360
361        // Get total number of versions for this record.
362        $qid = $db->query("
363            SELECT COUNT(*) FROM " . $db->escapeString($this->getParam('db_table')) . "
364            WHERE record_table = '" . $db->escapeString($record_table) . "'
365            AND record_key = '" . $db->escapeString($record_key) . "'
366            AND record_val = '" . $db->escapeString($record_val) . "'
367        ");
368        list($v_count) = mysql_fetch_row($qid);
369
370        if ($v_count > $this->getParam('min_qty')) {
371            if ($v_count > $this->getParam('max_qty')) {
372                // To prevent a record bomb, limit max number of versions to max_qty.
373                // First query for oldest records, selecting enough to bring total number down to min_qty.
374                $qid = $db->query("
375                    SELECT version_id
376                    FROM " . $db->escapeString($this->getParam('db_table')) . "
377                    WHERE record_table = '" . $db->escapeString($record_table) . "'
378                    AND record_key = '" . $db->escapeString($record_key) . "'
379                    AND record_val = '" . $db->escapeString($record_val) . "'
380                    ORDER BY version_datetime ASC
381                    LIMIT " . $db->escapeString($v_count - $this->getParam('min_qty')) . "
382                ");
383                $old_versions = array();
384                while (list($old_id) = mysql_fetch_row($qid)) {
385                    $old_versions[] = $old_id;
386                }
387                $db->query("
388                    DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
389                    WHERE version_id IN ('" . join("','", $old_versions) . "')
390                ");
391            } else {
392                // Delete versions older than min_days, while still keeping min_qty.
393                $qid = $db->query("
394                    SELECT version_id
395                    FROM " . $db->escapeString($this->getParam('db_table')) . "
396                    WHERE record_table = '" . $db->escapeString($record_table) . "'
397                    AND record_key = '" . $db->escapeString($record_key) . "'
398                    AND record_val = '" . $db->escapeString($record_val) . "'
399                    AND DATE_ADD(version_datetime, INTERVAL '" . $this->getParam('min_days') . "' DAY) < NOW()
400                    ORDER BY version_datetime ASC
401                    LIMIT " . ($v_count - $this->getParam('min_qty')) . "
402                ");
403                $old_versions = array();
404                while (list($old_id) = mysql_fetch_row($qid)) {
405                    $old_versions[] = $old_id;
406                }
407                if (sizeof($old_versions) > 0) {
408                    $db->query("
409                        DELETE FROM " . $db->escapeString($this->getParam('db_table')) . "
410                        WHERE version_id IN ('" . join("','", $old_versions) . "')
411                    ");
412                }
413            }
414        }
415    }
416
417    /**
418     * Get a list of versions of specified record.
419     *
420     * @param string $record_table  The table containing the record.
421     * @param string $record_key    The key column for the record.
422     * @param string $record_val    The value of the key column for the record.
423     *
424     * @return mixed                Array of versions, or false if none.
425     */
426    public function getList($record_table, $record_key, $record_val)
427    {
428        $db =& DB::getInstance();
429
430        $this->initDB();
431
432        // Get versions of this record.
433        $qid = $db->query("
434            SELECT
435                version_id,
436                saved_by_user_id,
437                version_datetime,
438                version_title,
439                version_number,
440                version_notes
441            FROM " . $db->escapeString($this->getParam('db_table')) . "
442            WHERE record_table = '" . $db->escapeString($record_table) . "'
443            AND record_key = '" . $db->escapeString($record_key) . "'
444            AND record_val = '" . $db->escapeString($record_val) . "'
445            ORDER BY version_datetime DESC
446        ");
447        $versions = array();
448        while ($row = mysql_fetch_assoc($qid)) {
449            // Get admin usernames.
450            $row['editor'] = $this->_auth->getUsername($row['saved_by_user_id']);
451            $versions[] = $row;
452        }
453        return $versions;
454    }
455
456    /**
457     * Get the version record for a specified version id.
458     *
459     * @param string $version_id    The id of the version to restore.
460     *
461     * @return mixed                Array of data saved in version, or false if none.
462     */
463    public function getVerson($version_id)
464    {
465        $db =& DB::getInstance();
466
467        $this->initDB();
468
469        // Get version data.
470        $qid = $db->query("
471            SELECT * FROM " . $db->escapeString($this->getParam('db_table')) . "
472            WHERE version_id = '" . $db->escapeString($version_id) . "'
473        ");
474        return mysql_fetch_assoc($qid);
475    }
476
477    /**
478     * Get the data stored for a specified version id.
479     *
480     * @param string $version_id    The id of the version to restore.
481     *
482     * @return mixed                Array of data saved in version, or false if none.
483     */
484    public function getData($version_id)
485    {
486        $db =& DB::getInstance();
487
488        $this->initDB();
489
490        // Get version data.
491        $qid = $db->query("
492            SELECT *
493            FROM " . $db->escapeString($this->getParam('db_table')) . "
494            WHERE version_id = '" . $db->escapeString($version_id) . "'
495        ");
496        $record = mysql_fetch_assoc($qid);
497        if (isset($record['version_data'])) {
498            // Unserialize the DB record.
499            switch ($this->getParam('serialization_method')) {
500            case 'phpserialize':
501                return unserialize(gzuncompress($record['version_data']));
502
503            case 'json':
504                return json_decode(gzuncompress($record['version_data']));
505            }
506        } else {
507            return false;
508        }
509    }
510
511    /**
512     * Get the current record data from the original table.
513     *
514     * @param string $version_id    The id of the version to restore.
515     *
516     * @return mixed                Array of data saved in version, or false if none.
517     */
518    public function getCurrent($record_table, $record_key, $record_val)
519    {
520        $db =& DB::getInstance();
521        $app =& App::getInstance();
522
523        $this->initDB();
524
525        if (!$record_table || !$record_key || !$record_val) {
526            $app->logMsg(sprintf('Invalid current version args: %s, %s, %s.', $record_table, $record_key, $record_val), LOG_ERR, __FILE__, __LINE__);
527            return false;
528        }
529
530        $qid = $db->query("
531            SELECT * FROM " . $db->escapeString($record_table) . "
532            WHERE " . $db->escapeString($record_key) . " = '" . $db->escapeString($record_val) . "'
533        ");
534        if ($record = mysql_fetch_assoc($qid)) {
535            return $record;
536        } else {
537            return false;
538        }
539    }
540
541
542} // End of class.
Note: See TracBrowser for help on using the repository browser.