[!!!][BUGFIX] Separate sys_history from sys_log db entries
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / History / RecordHistory.php
1 <?php
2 namespace TYPO3\CMS\Backend\History;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Backend\Utility\BackendUtility;
18 use TYPO3\CMS\Core\Database\ConnectionPool;
19 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
20 use TYPO3\CMS\Core\DataHandling\DataHandler;
21 use TYPO3\CMS\Core\History\RecordHistoryStore;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
23
24 /**
25 * Class for fetching the history entries of a record (and if it is a page, its subelements
26 * as well)
27 */
28 class RecordHistory
29 {
30 /**
31 * Maximum number of sys_history steps to show.
32 *
33 * @var int
34 */
35 protected $maxSteps = 20;
36
37 /**
38 * On a pages table - show sub elements as well.
39 *
40 * @var int
41 */
42 protected $showSubElements = 1;
43
44 /**
45 * Element reference, syntax [tablename]:[uid]
46 *
47 * @var string
48 */
49 protected $element;
50
51 /**
52 * sys_history uid which is selected
53 *
54 * @var int
55 */
56 public $lastHistoryEntry;
57
58 /**
59 * @var array
60 */
61 public $changeLog = [];
62
63 /**
64 * Internal cache
65 * @var array
66 */
67 protected $pageAccessCache = [];
68
69 /**
70 * Either "table:uid" or "table:uid:field" to know which data should be rolled back
71 * @var string
72 */
73 protected $rollbackFields = '';
74
75 /**
76 * Constructor to define which element to work on - can be overriden with "setLastHistoryEntry"
77 *
78 * @param string $element in the form of "tablename:uid"
79 * @param string $rollbackFields
80 */
81 public function __construct($element = '', $rollbackFields = '')
82 {
83 $this->element = $this->sanitizeElementValue($element);
84 $this->rollbackFields = $this->sanitizeRollbackFieldsValue($rollbackFields);
85 }
86
87 /**
88 * If a specific history entry is selected, then the relevant element is resolved for that.
89 *
90 * @param int $lastHistoryEntry
91 */
92 public function setLastHistoryEntry(int $lastHistoryEntry)
93 {
94 if ($lastHistoryEntry) {
95 $elementData = $this->getHistoryEntry($lastHistoryEntry);
96 $this->lastHistoryEntry = $lastHistoryEntry;
97 if (!empty($elementData) && empty($this->element)) {
98 $this->element = $elementData['tablename'] . ':' . $elementData['recuid'];
99 }
100 }
101 }
102
103 /**
104 * Define the maximum amount of history entries to be shown. Beware of side-effects when using
105 * "showSubElements" as well.
106 *
107 * @param int $maxSteps
108 */
109 public function setMaxSteps(int $maxSteps)
110 {
111 $this->maxSteps = $maxSteps;
112 }
113
114 /**
115 * Defines to show the history of a specific record or its subelements (when it's a page)
116 * as well.
117 *
118 * @param bool $showSubElements
119 */
120 public function setShowSubElements(bool $showSubElements)
121 {
122 $this->showSubElements = $showSubElements;
123 }
124
125 /**
126 * Creates change log including sub-elements, filling $this->changeLog
127 */
128 public function createChangeLog()
129 {
130 if (!empty($this->element)) {
131 list($table, $recordUid) = explode(':', $this->element);
132 $this->changeLog = $this->getHistoryData($table, $recordUid, $this->showSubElements, $this->lastHistoryEntry);
133 }
134 }
135
136 /**
137 * Whether rollback mode is on
138 * @return bool
139 */
140 public function shouldPerformRollback()
141 {
142 return !empty($this->rollbackFields);
143 }
144
145 /**
146 * An array (0 = tablename, 1 = uid) or false if no element is set
147 *
148 * @return array|bool
149 */
150 public function getElementData()
151 {
152 return !empty($this->element) ? explode(':', $this->element) : false;
153 }
154
155 /**
156 * @return string named "tablename:uid"
157 */
158 public function getElementString(): string
159 {
160 return (string)$this->element;
161 }
162
163 /**
164 * Perform rollback via DataHandler
165 */
166 public function performRollback()
167 {
168 if (!$this->shouldPerformRollback()) {
169 return;
170 }
171 $rollbackData = explode(':', $this->rollbackFields);
172 $diff = $this->createMultipleDiff();
173 // PROCESS INSERTS AND DELETES
174 // rewrite inserts and deletes
175 $cmdmapArray = [];
176 $data = [];
177 if ($diff['insertsDeletes']) {
178 switch (count($rollbackData)) {
179 case 1:
180 // all tables
181 $data = $diff['insertsDeletes'];
182 break;
183 case 2:
184 // one record
185 if ($diff['insertsDeletes'][$this->rollbackFields]) {
186 $data[$this->rollbackFields] = $diff['insertsDeletes'][$this->rollbackFields];
187 }
188 break;
189 case 3:
190 // one field in one record -- ignore!
191 break;
192 }
193 if (!empty($data)) {
194 foreach ($data as $key => $action) {
195 $elParts = explode(':', $key);
196 if ((int)$action === 1) {
197 // inserted records should be deleted
198 $cmdmapArray[$elParts[0]][$elParts[1]]['delete'] = 1;
199 // When the record is deleted, the contents of the record do not need to be updated
200 unset($diff['oldData'][$key]);
201 unset($diff['newData'][$key]);
202 } elseif ((int)$action === -1) {
203 // deleted records should be inserted again
204 $cmdmapArray[$elParts[0]][$elParts[1]]['undelete'] = 1;
205 }
206 }
207 }
208 }
209 // Writes the data:
210 if ($cmdmapArray) {
211 $tce = GeneralUtility::makeInstance(DataHandler::class);
212 $tce->dontProcessTransformations = true;
213 $tce->start([], $cmdmapArray);
214 $tce->process_cmdmap();
215 unset($tce);
216 }
217 // PROCESS CHANGES
218 // create an array for process_datamap
219 $diffModified = [];
220 foreach ($diff['oldData'] as $key => $value) {
221 $splitKey = explode(':', $key);
222 $diffModified[$splitKey[0]][$splitKey[1]] = $value;
223 }
224 switch (count($rollbackData)) {
225 case 1:
226 // all tables
227 $data = $diffModified;
228 break;
229 case 2:
230 // one record
231 $data[$rollbackData[0]][$rollbackData[1]] = $diffModified[$rollbackData[0]][$rollbackData[1]];
232 break;
233 case 3:
234 // one field in one record
235 $data[$rollbackData[0]][$rollbackData[1]][$rollbackData[2]] = $diffModified[$rollbackData[0]][$rollbackData[1]][$rollbackData[2]];
236 break;
237 }
238 // Removing fields:
239 $data = $this->removeFilefields($rollbackData[0], $data);
240 // Writes the data:
241 $tce = GeneralUtility::makeInstance(DataHandler::class);
242 $tce->dontProcessTransformations = true;
243 $tce->start($data, []);
244 $tce->process_datamap();
245 unset($tce);
246
247 // Return to normal operation
248 $this->lastHistoryEntry = false;
249 $this->rollbackFields = '';
250 $this->createChangeLog();
251 if (isset($data['pages']) || isset($cmdmapArray['pages'])) {
252 BackendUtility::setUpdateSignal('updatePageTree');
253 }
254 }
255
256 /*******************************
257 *
258 * build up history
259 *
260 *******************************/
261
262 /**
263 * Creates a diff between the current version of the records and the selected version
264 *
265 * @return array Diff for many elements
266 */
267 public function createMultipleDiff(): array
268 {
269 $insertsDeletes = [];
270 $newArr = [];
271 $differences = [];
272 // traverse changelog array
273 foreach ($this->changeLog as $value) {
274 $field = $value['tablename'] . ':' . $value['recuid'];
275 // inserts / deletes
276 if ((int)$value['actiontype'] !== RecordHistoryStore::ACTION_MODIFY) {
277 if (!$insertsDeletes[$field]) {
278 $insertsDeletes[$field] = 0;
279 }
280 if ($value['action'] === 'insert') {
281 $insertsDeletes[$field]++;
282 } else {
283 $insertsDeletes[$field]--;
284 }
285 // unset not needed fields
286 if ($insertsDeletes[$field] === 0) {
287 unset($insertsDeletes[$field]);
288 }
289 } else {
290 // update fields
291 // first row of field
292 if (!isset($newArr[$field])) {
293 $newArr[$field] = $value['newRecord'];
294 $differences[$field] = $value['oldRecord'];
295 } else {
296 // standard
297 $differences[$field] = array_merge($differences[$field], $value['oldRecord']);
298 }
299 }
300 }
301 // remove entries where there were no changes effectively
302 foreach ($newArr as $record => $value) {
303 foreach ($value as $key => $innerVal) {
304 if ($newArr[$record][$key] == $differences[$record][$key]) {
305 unset($newArr[$record][$key]);
306 unset($differences[$record][$key]);
307 }
308 }
309 if (empty($newArr[$record]) && empty($differences[$record])) {
310 unset($newArr[$record]);
311 unset($differences[$record]);
312 }
313 }
314 return [
315 'newData' => $newArr,
316 'oldData' => $differences,
317 'insertsDeletes' => $insertsDeletes
318 ];
319 }
320
321 /**
322 * Fetches the history data of a record + includes subelements if this is from a page
323 *
324 * @param string $table
325 * @param int $uid
326 * @param bool $includeSubentries
327 * @param int $lastHistoryEntry the highest entry to be evaluated
328 * @return array
329 */
330 public function getHistoryData(string $table, int $uid, bool $includeSubentries = null, int $lastHistoryEntry = null): array
331 {
332 $changeLog = $this->getHistoryDataForRecord($table, $uid, $lastHistoryEntry);
333 // get history of tables of this page and merge it into changelog
334 if ($table === 'pages' && $includeSubentries && $this->hasPageAccess('pages', $uid)) {
335 foreach ($GLOBALS['TCA'] as $tablename => $value) {
336 // check if there are records on the page
337 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tablename);
338 $queryBuilder->getRestrictions()->removeAll();
339
340 $rows = $queryBuilder
341 ->select('uid')
342 ->from($tablename)
343 ->where(
344 $queryBuilder->expr()->eq(
345 'pid',
346 $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
347 )
348 )
349 ->execute();
350 if ($rows->rowCount() === 0) {
351 continue;
352 }
353 foreach ($rows as $row) {
354 // if there is history data available, merge it into changelog
355 $newChangeLog = $this->getHistoryDataForRecord($tablename, $row['uid'], $lastHistoryEntry);
356 if (is_array($newChangeLog) && !empty($newChangeLog)) {
357 foreach ($newChangeLog as $key => $newChangeLogEntry) {
358 $changeLog[$key] = $newChangeLogEntry;
359 }
360 }
361 }
362 }
363 }
364 usort($changeLog, function ($a, $b) {
365 if ($a['tstamp'] < $b['tstamp']) {
366 return 1;
367 }
368 if ($a['tstamp'] > $b['tstamp']) {
369 return -1;
370 }
371 return 0;
372 });
373 return $changeLog;
374 }
375
376 /**
377 * Gets history and delete/insert data from sys_log and sys_history
378 *
379 * @param string $table DB table name
380 * @param int $uid UID of record
381 * @param int $lastHistoryEntry the highest entry to be fetched
382 * @return array Array of history data of the record
383 */
384 public function getHistoryDataForRecord(string $table, int $uid, int $lastHistoryEntry = null): array
385 {
386 if (empty($GLOBALS['TCA'][$table]) || !$this->hasTableAccess($table) || !$this->hasPageAccess($table, $uid)) {
387 return [];
388 }
389
390 $uid = $this->resolveElement($table, $uid);
391 return $this->findEventsForRecord($table, $uid, ($this->maxSteps ?: null), $lastHistoryEntry);
392 }
393
394 /*******************************
395 *
396 * Various helper functions
397 *
398 *******************************/
399
400 /**
401 * Will traverse the field names in $dataArray and look in $GLOBALS['TCA'] if the fields are of types which cannot
402 * be handled by the sys_history (that is currently group types with internal_type set to "file")
403 *
404 * @param string $table Table name
405 * @param array $dataArray The data array
406 * @return array The modified data array
407 * @access private
408 */
409 protected function removeFilefields($table, $dataArray)
410 {
411 if ($GLOBALS['TCA'][$table]) {
412 foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $config) {
413 if ($config['config']['type'] === 'group' && $config['config']['internal_type'] === 'file') {
414 unset($dataArray[$field]);
415 }
416 }
417 }
418 return $dataArray;
419 }
420
421 /**
422 * Convert input element reference to workspace version if any.
423 *
424 * @param string $table Table of input element
425 * @param int $uid UID of record
426 * @return int converted UID of record
427 */
428 protected function resolveElement(string $table, int $uid): int
429 {
430 if (isset($GLOBALS['TCA'][$table])
431 && $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->getBackendUser()->workspace, $table, $uid, 'uid')) {
432 $uid = $workspaceVersion['uid'];
433 }
434 return $uid;
435 }
436
437 /**
438 * Resolve tablename + record uid from sys_history UID
439 *
440 * @param int $lastHistoryEntry
441 * @return array
442 */
443 public function getHistoryEntry(int $lastHistoryEntry): array
444 {
445 $queryBuilder = $this->getQueryBuilder();
446 $record = $queryBuilder
447 ->select('uid', 'tablename', 'recuid')
448 ->from('sys_history')
449 ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($lastHistoryEntry, \PDO::PARAM_INT)))
450 ->execute()
451 ->fetch();
452
453 if (empty($record)) {
454 return [];
455 }
456
457 return $record;
458 }
459
460 /**
461 * Queries the DB and prepares the results
462 * Resolving a WSOL of the UID and checking permissions is explicitly not part of this method
463 *
464 * @param string $table
465 * @param int $uid
466 * @param int $limit
467 * @param int $minimumUid
468 * @return array
469 */
470 public function findEventsForRecord(string $table, int $uid, int $limit = 0, int $minimumUid = null): array
471 {
472 $events = [];
473 $queryBuilder = $this->getQueryBuilder();
474 $queryBuilder
475 ->select('*')
476 ->from('sys_history')
477 ->where(
478 $queryBuilder->expr()->eq('tablename', $queryBuilder->createNamedParameter($table, \PDO::PARAM_STR)),
479 $queryBuilder->expr()->eq('recuid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT))
480 );
481
482 if ($limit) {
483 $queryBuilder->setMaxResults($limit);
484 }
485
486 if ($minimumUid) {
487 $queryBuilder->andWhere($queryBuilder->expr()->gte('uid', $queryBuilder->createNamedParameter($minimumUid, \PDO::PARAM_INT)));
488 }
489
490 $result = $queryBuilder->orderBy('tstamp', 'DESC')->execute();
491 while ($row = $result->fetch()) {
492 $identifier = (int)$row['uid'];
493 if ((int)$row['actiontype'] === RecordHistoryStore::ACTION_ADD || (int)$row['actiontype'] === RecordHistoryStore::ACTION_UNDELETE) {
494 $row['action'] = 'insert';
495 }
496 if ((int)$row['actiontype'] === RecordHistoryStore::ACTION_DELETE) {
497 $row['action'] = 'delete';
498 }
499 if (strpos($row['history_data'], 'a') === 0) {
500 // legacy code
501 $row['history_data'] = unserialize($row['history_data'], ['allowed_classes' => false]);
502 } else {
503 $row['history_data'] = json_decode($row['history_data'], true);
504 }
505 if (isset($row['history_data']['newRecord'])) {
506 $row['newRecord'] = $row['history_data']['newRecord'];
507 }
508 if (isset($row['history_data']['oldRecord'])) {
509 $row['oldRecord'] = $row['history_data']['oldRecord'];
510 }
511 $events[$identifier] = $row;
512 }
513 krsort($events);
514 return $events;
515 }
516
517 /**
518 * Determines whether user has access to a page.
519 *
520 * @param string $table
521 * @param int $uid
522 * @return bool
523 */
524 protected function hasPageAccess($table, $uid)
525 {
526 $uid = (int)$uid;
527
528 if ($table === 'pages') {
529 $pageId = $uid;
530 } else {
531 $record = BackendUtility::getRecord($table, $uid, '*', '', false);
532 $pageId = $record['pid'];
533 }
534
535 if (!isset($this->pageAccessCache[$pageId])) {
536 $this->pageAccessCache[$pageId] = BackendUtility::readPageAccess(
537 $pageId,
538 $this->getBackendUser()->getPagePermsClause(1)
539 );
540 }
541
542 return $this->pageAccessCache[$pageId] !== false;
543 }
544
545 /**
546 * Fetches GET/POST arguments and sanitizes the values for
547 * the expected disposal. Invalid values will be converted
548 * to an empty string.
549 *
550 * @param string $value the value of the element value
551 * @return array|string|int
552 */
553 protected function sanitizeElementValue($value)
554 {
555 if ($value !== '' && !preg_match('#^[a-z0-9_.]+:[0-9]+$#i', $value)) {
556 return '';
557 }
558 return $value;
559 }
560
561 /**
562 * Evaluates if the rollback field is correct
563 *
564 * @param string $value
565 * @return string
566 */
567 protected function sanitizeRollbackFieldsValue($value)
568 {
569 if ($value !== '' && !preg_match('#^[a-z0-9_.]+(:[0-9]+(:[a-z0-9_.]+)?)?$#i', $value)) {
570 return '';
571 }
572 return $value;
573 }
574
575 /**
576 * Determines whether user has access to a table.
577 *
578 * @param string $table
579 * @return bool
580 */
581 protected function hasTableAccess($table)
582 {
583 return $this->getBackendUser()->check('tables_select', $table);
584 }
585
586 /**
587 * Gets the current backend user.
588 *
589 * @return \TYPO3\CMS\Core\Authentication\BackendUserAuthentication
590 */
591 protected function getBackendUser()
592 {
593 return $GLOBALS['BE_USER'];
594 }
595
596 /**
597 * @return QueryBuilder
598 */
599 protected function getQueryBuilder(): QueryBuilder
600 {
601 return GeneralUtility::makeInstance(ConnectionPool::class)
602 ->getQueryBuilderForTable('sys_history');
603 }
604 }