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