1 <?php
  2 
  3 /**
  4  * @package redaxo\backup
  5  */
  6 class rex_backup
  7 {
  8     const IMPORT_ARCHIVE = 1;
  9     const IMPORT_DB = 2;
 10     const IMPORT_EVENT_PRE = 3;
 11     const IMPORT_EVENT_POST = 4;
 12 
 13     public static function getDir()
 14     {
 15         $dir = rex_path::addonData('backup');
 16         rex_dir::create($dir);
 17 
 18         return $dir;
 19     }
 20 
 21     public static function getBackupFiles($filePrefix)
 22     {
 23         $dir = self::getDir();
 24 
 25         $folder = rex_finder::factory($dir)->filesOnly();
 26 
 27         $filtered = [];
 28         foreach ($folder as $file) {
 29             $file = $file->getFilename();
 30             if (substr($file, strlen($file) - strlen($filePrefix)) == $filePrefix) {
 31                 $filtered[] = $file;
 32             }
 33         }
 34         $folder = $filtered;
 35 
 36         usort($folder, function ($file_a, $file_b) use ($dir) {
 37             $time_a = filemtime($dir . '/' . $file_a);
 38             $time_b = filemtime($dir . '/' . $file_b);
 39 
 40             if ($time_a == $time_b) {
 41                 return 0;
 42             }
 43 
 44             return ($time_a > $time_b) ? -1 : 1;
 45         });
 46 
 47         return $folder;
 48     }
 49 
 50     /**
 51      * Importiert den SQL Dump $filename in die Datenbank.
 52      *
 53      * @param string $filename Pfad + Dateinamen zur SQL-Datei
 54      *
 55      * @return array Gibt ein Assoc. Array zurück.
 56      *               'state' => boolean (Status ob fehler aufgetreten sind)
 57      *               'message' => Evtl. Status/Fehlermeldung
 58      */
 59     public static function importDb($filename)
 60     {
 61         $return = [];
 62         $return['state'] = false;
 63         $return['message'] = '';
 64 
 65         $msg = '';
 66         $error = '';
 67 
 68         if ($filename == '' || substr($filename, -4, 4) != '.sql') {
 69             $return['message'] = rex_i18n::msg('backup_no_import_file_chosen_or_wrong_version') . '<br>';
 70             return $return;
 71         }
 72 
 73         $conts = rex_file::get($filename);
 74 
 75         // Versionsstempel prüfen
 76         // ## Redaxo Database Dump Version x.x
 77         $mainVersion = rex::getVersion('%s');
 78         $version = strpos($conts, '## Redaxo Database Dump Version ' . $mainVersion);
 79         if ($version === false) {
 80             $return['message'] = rex_i18n::msg('backup_no_valid_import_file') . '. [## Redaxo Database Dump Version ' . $mainVersion . '] is missing';
 81             return $return;
 82         }
 83         // Versionsstempel entfernen
 84         $conts = trim(str_replace('## Redaxo Database Dump Version ' . $mainVersion, '', $conts));
 85 
 86         // Prefix prüfen
 87         // ## Prefix xxx_
 88         if (preg_match('/^## Prefix ([a-zA-Z0-9\_]*)/', $conts, $matches) && isset($matches[1])) {
 89             // prefix entfernen
 90             $prefix = $matches[1];
 91             $conts = trim(str_replace('## Prefix ' . $prefix, '', $conts));
 92         } else {
 93             // Prefix wurde nicht gefunden
 94             $return['message'] = rex_i18n::msg('backup_no_valid_import_file') . '. [## Prefix ' . rex::getTablePrefix() . '] is missing';
 95             return $return;
 96         }
 97 
 98         // Charset prüfen
 99         // ## charset xxx_
100         if (preg_match('/^## charset ([a-zA-Z0-9\_\-]*)/', $conts, $matches) && isset($matches[1])) {
101             // charset entfernen
102             $charset = $matches[1];
103             $conts = trim(str_replace('## charset ' . $charset, '', $conts));
104 
105             $rexCharset = 'utf-8';
106             if ($rexCharset != $charset) {
107                 $return['message'] = rex_i18n::msg('backup_no_valid_charset') . '. ' . $rexCharset . ' != ' . $charset;
108                 return $return;
109             }
110         }
111 
112         // Prefix im export mit dem der installation angleichen
113         if (rex::getTablePrefix() != $prefix) {
114             // Hier case-insensitiv ersetzen, damit alle möglich Schreibweisen (TABLE TablE, tAblE,..) ersetzt werden
115             // Dies ist wichtig, da auch SQLs innerhalb von Ein/Ausgabe der Module vom rex-admin verwendet werden
116             $conts = preg_replace('/(TABLES? `?)' . preg_quote($prefix, '/') . '/i', '$1' . rex::getTablePrefix(), $conts);
117             $conts = preg_replace('/(INTO `?)'  . preg_quote($prefix, '/') . '/i', '$1' . rex::getTablePrefix(), $conts);
118             $conts = preg_replace('/(EXISTS `?)' . preg_quote($prefix, '/') . '/i', '$1' . rex::getTablePrefix(), $conts);
119         }
120 
121         // ----- EXTENSION POINT
122         $filesize = filesize($filename);
123         $msg = rex_extension::registerPoint(new rex_extension_point('BACKUP_BEFORE_DB_IMPORT', $msg, [
124             'content' => $conts,
125             'filename' => $filename,
126             'filesize' => $filesize,
127         ]));
128 
129         // require import skript to do some userside-magic
130         self::importScript(str_replace('.sql', '.php', $filename), self::IMPORT_DB, self::IMPORT_EVENT_PRE);
131 
132         // Datei aufteilen
133         $lines = [];
134         rex_sql_util::splitSqlFile($lines, $conts, 0);
135 
136         $sql = rex_sql::factory();
137         foreach ($lines as $line) {
138             try {
139                 $sql->setQuery($line['query']);
140             } catch (rex_sql_exception $e) {
141                 $error .= "\n" . $e->getMessage();
142             }
143         }
144 
145         if ($error != '') {
146             $return['message'] = trim($error);
147             return $return;
148         }
149 
150         $msg .= rex_i18n::msg('backup_database_imported') . '. ' . rex_i18n::msg('backup_entry_count', count($lines)) . '<br />';
151         unset($lines);
152 
153         // prüfen, ob eine user tabelle angelegt wurde
154         $tables = rex_sql::factory()->getTables(rex::getTablePrefix());
155         $user_table_found = in_array(rex::getTablePrefix() . 'user', $tables);
156 
157         if (!$user_table_found) {
158             $create_user_table = '
159              CREATE TABLE ' . rex::getTablePrefix() . 'user
160              (
161                  id int(11) NOT NULL auto_increment,
162                  name varchar(255) NOT NULL,
163                  description text NOT NULL,
164                  login varchar(50) NOT NULL,
165                  psw varchar(50) NOT NULL,
166                  status varchar(5) NOT NULL,
167                  role int(11) NOT NULL,
168                  rights text NOT NULL,
169                  login_tries tinyint(4) NOT NULL DEFAULT 0,
170                  createuser varchar(255) NOT NULL,
171                  updateuser varchar(255) NOT NULL,
172                  createdate datetime NOT NULL,
173                  updatedate datetime NOT NULL,
174                  lasttrydate datetime NOT NULL,
175                  session_id varchar(255) NOT NULL,
176                  PRIMARY KEY(id)
177              ) ENGINE=InnoDB  DEFAULT CHARSET=utf8;';
178             $db = rex_sql::factory();
179             try {
180                 $db->setQuery($create_user_table);
181             } catch (rex_sql_exception $e) {
182                 // evtl vorhergehende meldungen löschen, damit nur der fehler angezeigt wird
183                 $msg = '';
184                 $msg .= $e->getMessage();
185             }
186         }
187 
188         $user_role_table_found = in_array(rex::getTablePrefix() . 'user_role', $tables);
189         if (!$user_role_table_found) {
190             $create_user_role_table = '
191              CREATE TABLE ' . rex::getTablePrefix() . 'user_role
192              (
193                  id int(11) NOT NULL auto_increment,
194                  name varchar(255) NOT NULL,
195                  description text NOT NULL,
196                  rights text NOT NULL,
197                  createuser varchar(255) NOT NULL,
198                  updateuser varchar(255) NOT NULL,
199                  createdate datetime NOT NULL DEFAULT 0,
200                  updatedate datetime NOT NULL DEFAULT 0
201                  PRIMARY KEY(id)
202              ) ENGINE=InnoDB  DEFAULT CHARSET=utf8;';
203             $db = rex_sql::factory();
204             try {
205                 $db->setQuery($create_user_role_table);
206             } catch (rex_sql_exception $e) {
207                 // evtl vorhergehende meldungen löschen, damit nur der fehler angezeigt wird
208                 $msg = '';
209                 $msg .= $e->getMessage();
210             }
211         }
212 
213         // generated neu erstellen, wenn kein Fehler aufgetreten ist
214         if ($error == '') {
215             // delete cache before EP to avoid obsolete caches while running extensions
216             rex_delete_cache();
217 
218             // refresh rex_config with new values from database
219             rex_config::refresh();
220 
221             // ----- EXTENSION POINT
222             $msg = rex_extension::registerPoint(new rex_extension_point('BACKUP_AFTER_DB_IMPORT', $msg, [
223                 'content' => $conts,
224                 'filename' => $filename,
225                 'filesize' => $filesize,
226             ]));
227 
228             // require import skript to do some userside-magic
229             self::importScript(str_replace('.sql', '.php', $filename), self::IMPORT_DB, self::IMPORT_EVENT_POST);
230 
231             // delete cache again because the extensions and the php script could have changed data again
232             $msg .= rex_delete_cache();
233             $return['state'] = true;
234         }
235 
236         $return['message'] = $msg;
237 
238         return $return;
239     }
240 
241     /**
242      * Importiert das Tar-Archiv $filename in den Ordner /files.
243      *
244      * @param string $filename Pfad + Dateinamen zum Tar-Archiv
245      *
246      * @return array Gibt ein Assoc. Array zurück.
247      *               'state' => boolean (Status ob fehler aufgetreten sind)
248      *               'message' => Evtl. Status/Fehlermeldung
249      */
250     public static function importFiles($filename)
251     {
252         $return = [];
253         $return['state'] = false;
254 
255         if ($filename == '' || substr($filename, -7, 7) != '.tar.gz') {
256             $return['message'] = rex_i18n::msg('backup_no_import_file_chosen') . '<br />';
257             return $return;
258         }
259 
260         // Ordner /files komplett leeren
261         rex_dir::deleteFiles(rex_path::media());
262 
263         $tar = new rex_backup_tar();
264 
265         // ----- EXTENSION POINT
266         $tar = rex_extension::registerPoint(new rex_extension_point('BACKUP_BEFORE_FILE_IMPORT', $tar));
267 
268         // require import skript to do some userside-magic
269         self::importScript(str_replace('.tar.gz', '.php', $filename), self::IMPORT_ARCHIVE, self::IMPORT_EVENT_PRE);
270 
271         $tar->openTAR($filename);
272         if (!$tar->extractTar()) {
273             $msg = rex_i18n::msg('backup_problem_when_extracting') . '<br />';
274             if (count($tar->getMessages()) > 0) {
275                 $msg .= rex_i18n::msg('backup_create_dirs_manually') . '<br />';
276                 foreach ($tar->getMessages() as $_message) {
277                     $msg .= rex_path::absolute($_message) . '<br />';
278                 }
279             }
280         } else {
281             $msg = rex_i18n::msg('backup_file_imported') . '<br />';
282         }
283 
284         // ----- EXTENSION POINT
285         $tar = rex_extension::registerPoint(new rex_extension_point('BACKUP_AFTER_FILE_IMPORT', $tar));
286 
287         // require import skript to do some userside-magic
288         self::importScript(str_replace('.tar.gz', '.php', $filename), self::IMPORT_ARCHIVE, self::IMPORT_EVENT_POST);
289 
290         $return['state'] = true;
291         $return['message'] = $msg;
292         return $return;
293     }
294 
295     /**
296      * Erstellt einen SQL Dump, der die aktuellen Datebankstruktur darstellt.
297      * Dieser wird in der Datei $filename gespeichert.
298      *
299      * @param string $filename
300      * @param array  $tables
301      *
302      * @return bool TRUE wenn ein Dump erstellt wurde, sonst FALSE
303      */
304     public static function exportDb($filename, array $tables = null)
305     {
306         $fp = @tmpfile();
307         $tempCacheFile = null;
308 
309         // in case of permission issues/misconfigured tmp-folders
310         if (!$fp) {
311             $tempCacheFile = rex_path::cache(basename($filename));
312             $fp = fopen($tempCacheFile, 'w');
313             if (!$fp) {
314                 return false;
315             }
316         }
317 
318         $sql = rex_sql::factory();
319 
320         $nl = "\n";
321         $insertSize = 4000;
322 
323         // ----- EXTENSION POINT
324         rex_extension::registerPoint(new rex_extension_point('BACKUP_BEFORE_DB_EXPORT'));
325 
326         // Versionsstempel hinzufügen
327         fwrite($fp, '## Redaxo Database Dump Version ' . rex::getVersion('%s') . $nl);
328         fwrite($fp, '## Prefix ' . rex::getTablePrefix() . $nl);
329         fwrite($fp, '## charset utf-8' . $nl . $nl);
330         //  fwrite($fp, '/*!40110 START TRANSACTION; */'.$nl);
331 
332         fwrite($fp, 'SET FOREIGN_KEY_CHECKS = 0;' . $nl . $nl);
333 
334         if (null === $tables) {
335             $tables = [];
336             foreach (rex_sql::factory()->getTables(rex::getTablePrefix()) as $table) {
337                 if ($table != rex::getTable('user') // User Tabelle nicht exportieren
338                     && substr($table, 0, strlen(rex::getTablePrefix() . rex::getTempPrefix())) != rex::getTablePrefix() . rex::getTempPrefix()
339                 ) { // Tabellen die mit rex_tmp_ beginnne, werden nicht exportiert!
340                     $tables[] = $table;
341                 }
342             }
343         }
344         foreach ($tables as $table) {
345             //---- export metadata
346             $create = rex_sql::showCreateTable($table);
347 
348             fwrite($fp, 'DROP TABLE IF EXISTS ' . $sql->escapeIdentifier($table) . ';' . $nl);
349             fwrite($fp, $create . ';' . $nl);
350 
351             $fields = $sql->getArray('SHOW FIELDS FROM ' . $sql->escapeIdentifier($table));
352 
353             foreach ($fields as &$field) {
354                 if (preg_match('#^(bigint|int|smallint|mediumint|tinyint|timestamp)#i', $field['Type'])) {
355                     $field = 'int';
356                 } elseif (preg_match('#^(float|double|decimal)#', $field['Type'])) {
357                     $field = 'double';
358                 } elseif (preg_match('#^(char|varchar|text|longtext|mediumtext|tinytext)#', $field['Type'])) {
359                     $field = 'string';
360                 } elseif (preg_match('#^(date|datetime|time|timestamp|year)#', $field['Type'])) {
361                     // types which can be passed tru 1:1 as escaping isn't necessary, because we know the mysql internal format.
362                     $field = 'raw';
363                 }
364                 // else ?
365             }
366 
367             //---- export tabledata
368             $start = 0;
369             $max = $insertSize;
370 
371             do {
372                 $array = $sql->getArray('SELECT * FROM ' . $sql->escapeIdentifier($table) . ' LIMIT ' . $start . ',' . $max, [], PDO::FETCH_NUM);
373                 $count = $sql->getRows();
374 
375                 if ($count > 0 && $start == 0) {
376                     fwrite($fp, $nl . 'LOCK TABLES ' . $sql->escapeIdentifier($table) . ' WRITE;');
377                     fwrite($fp, $nl . '/*!40000 ALTER TABLE ' . $sql->escapeIdentifier($table) . ' DISABLE KEYS */;');
378                 } elseif ($count == 0) {
379                     break;
380                 }
381 
382                 $start += $max;
383                 $values = [];
384 
385                 foreach ($array as $row) {
386                     $record = [];
387 
388                     foreach ($fields as $idx => $type) {
389                         $column = $row[$idx];
390 
391                         switch ($type) {
392                             // prevent calling sql->escape() on values with a known format
393                             case 'raw':
394                                 $record[] = "'". $column ."'";
395                                 break;
396                             case 'int':
397                                 $record[] = (int) $column;
398                                 break;
399                             case 'double':
400                                 $record[] = sprintf('%.10F', (float) $column);
401                                 break;
402                             case 'string':
403                                 // fast-exit for very frequent used harmless values
404                                 if ($column === '0' || $column === '' || $column === ' ' || $column === '|' || $column === '||') {
405                                     $record[] = "'". $column ."'";
406                                     break;
407                                 }
408 
409                                 // fast-exit for very frequent used harmless values
410                                 if (strlen($column) <= 3 && ctype_alnum($column)) {
411                                     $record[] = "'". $column ."'";
412                                     break;
413                                 }
414                                 // no break
415                             default:
416                                 $record[] = $sql->escape($column);
417                                 break;
418                         }
419                     }
420 
421                     $values[] = $nl . '  (' . implode(',', $record) . ')';
422                 }
423 
424                 if (!empty($values)) {
425                     fwrite($fp, $nl . 'INSERT INTO ' . $sql->escapeIdentifier($table) . ' VALUES ' . implode(',', $values) . ';');
426                     unset($values);
427                 }
428             } while ($count >= $max);
429 
430             if ($start > 0) {
431                 fwrite($fp, $nl . '/*!40000 ALTER TABLE ' . $sql->escapeIdentifier($table) . ' ENABLE KEYS */;');
432                 fwrite($fp, $nl . 'UNLOCK TABLES;' . $nl . $nl);
433             }
434         }
435 
436         fwrite($fp, 'SET FOREIGN_KEY_CHECKS = 1;' . $nl);
437 
438         $hasContent = true;
439 
440         // Den Dateiinhalt geben wir nur dann weiter, wenn es unbedingt notwendig ist.
441         if (rex_extension::isRegistered('BACKUP_AFTER_DB_EXPORT')) {
442             $content = rex_file::get($filename);
443             $hashBefore = md5($content);
444             // ----- EXTENSION POINT
445             $content = rex_extension::registerPoint(new rex_extension_point('BACKUP_AFTER_DB_EXPORT', $content));
446             $hashAfter = md5($content);
447 
448             if ($hashAfter != $hashBefore) {
449                 rex_file::put($filename, $content);
450                 $hasContent = !empty($content);
451                 unset($content);
452             }
453         }
454 
455         // Wenn das backup vollständig und erfolgreich erzeugt werden konnte, den Export 1:1 ans Ziel kopieren.
456         if ($tempCacheFile) {
457             fclose($fp);
458             rename($tempCacheFile, $filename);
459         } else {
460             $destination = fopen($filename, 'w');
461             rewind($fp);
462             if (!$destination) {
463                 return false;
464             }
465             stream_copy_to_stream($fp, $destination);
466             fclose($fp);
467             fclose($destination);
468         }
469 
470         return $hasContent;
471     }
472 
473     /**
474      * Exportiert alle Ordner $folders aus dem Verzeichnis /files.
475      *
476      * @param array $folders Array von Ordnernamen, die exportiert werden sollen
477      *
478      * @return string Inhalt des Tar-Archives als String
479      */
480     public static function exportFiles($folders)
481     {
482         $tar = new rex_backup_tar();
483 
484         // ----- EXTENSION POINT
485         $tar = rex_extension::registerPoint(new rex_extension_point('BACKUP_BEFORE_FILE_EXPORT', $tar));
486 
487         foreach ($folders as $item) {
488             self::addFolderToTar($tar, rex_url::frontend(), $item);
489         }
490 
491         // ----- EXTENSION POINT
492         $tar = rex_extension::registerPoint(new rex_extension_point('BACKUP_AFTER_FILE_EXPORT', $tar));
493 
494         return $tar->toTar(null, true);
495     }
496 
497     /**
498      * Fügt einem Tar-Archiv ein Ordner von Dateien hinzu.
499      */
500     private static function addFolderToTar(rex_backup_tar $tar, $path, $dir)
501     {
502         $handle = opendir($path . $dir);
503         $isMediafolder = realpath($path . $dir) . '/' == rex_path::media();
504         while (false !== ($file = readdir($handle))) {
505             // Alles exportieren, außer ...
506             // - addons verzeichnis im mediafolder (wird bei addoninstallation wiedererstellt)
507             // - svn infos
508             // - tmp prefix Dateien
509 
510             if ($file == '.' || $file == '..' || $file == '.svn') {
511                 continue;
512             }
513 
514             if (substr($file, 0, strlen(rex::getTempPrefix())) == rex::getTempPrefix()) {
515                 continue;
516             }
517 
518             if ($isMediafolder && $file == 'addons') {
519                 continue;
520             }
521 
522             if (is_dir($path . $dir . '/' . $file)) {
523                 self::addFolderToTar($tar, $path . $dir . '/', $file);
524             } else {
525                 $tar->addFile($path . $dir . '/' . $file);
526             }
527         }
528         closedir($handle);
529     }
530 
531     private static function importScript($filename, $importType, $eventType)
532     {
533         if (file_exists($filename)) {
534             require $filename;
535         }
536     }
537 }
538