Passed
Push — master ( d26958...e190d9 )
by
unknown
17:02 queued 01:40
created

DataHandler::discardMmRelations()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 14
c 1
b 0
f 0
dl 0
loc 23
rs 9.4888
cc 5
nc 8
nop 2
1
<?php
2
3
/*
4
 * This file is part of the TYPO3 CMS project.
5
 *
6
 * It is free software; you can redistribute it and/or modify it under
7
 * the terms of the GNU General Public License, either version 2
8
 * of the License, or any later version.
9
 *
10
 * For the full copyright and license information, please read the
11
 * LICENSE.txt file that was distributed with this source code.
12
 *
13
 * The TYPO3 project - inspiring people to share!
14
 */
15
16
namespace TYPO3\CMS\Core\DataHandling;
17
18
use Doctrine\DBAL\Driver\Statement;
19
use Doctrine\DBAL\Exception as DBALException;
20
use Doctrine\DBAL\Platforms\PostgreSQL94Platform as PostgreSqlPlatform;
21
use Doctrine\DBAL\Platforms\SQLServer2012Platform as SQLServerPlatform;
22
use Doctrine\DBAL\Types\IntegerType;
23
use Psr\Log\LoggerAwareInterface;
24
use Psr\Log\LoggerAwareTrait;
25
use TYPO3\CMS\Backend\Utility\BackendUtility;
26
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
27
use TYPO3\CMS\Core\Cache\CacheManager;
28
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
29
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidIdentifierException;
30
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowException;
31
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowLoopException;
32
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowRootException;
33
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidPointerFieldValueException;
34
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
35
use TYPO3\CMS\Core\Configuration\Richtext;
36
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
37
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
38
use TYPO3\CMS\Core\Database\Connection;
39
use TYPO3\CMS\Core\Database\ConnectionPool;
40
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
41
use TYPO3\CMS\Core\Database\Query\QueryHelper;
42
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
43
use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface;
44
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
45
use TYPO3\CMS\Core\Database\RelationHandler;
46
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
47
use TYPO3\CMS\Core\DataHandling\Localization\DataMapProcessor;
48
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
49
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
50
use TYPO3\CMS\Core\Html\RteHtmlParser;
51
use TYPO3\CMS\Core\Localization\LanguageService;
52
use TYPO3\CMS\Core\Messaging\FlashMessage;
53
use TYPO3\CMS\Core\Messaging\FlashMessageService;
54
use TYPO3\CMS\Core\Resource\ResourceFactory;
55
use TYPO3\CMS\Core\Service\OpcodeCacheService;
56
use TYPO3\CMS\Core\SysLog\Action as SystemLogGenericAction;
57
use TYPO3\CMS\Core\SysLog\Action\Cache as SystemLogCacheAction;
58
use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
59
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
60
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
61
use TYPO3\CMS\Core\Type\Bitmask\Permission;
62
use TYPO3\CMS\Core\Utility\ArrayUtility;
63
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
64
use TYPO3\CMS\Core\Utility\GeneralUtility;
65
use TYPO3\CMS\Core\Utility\MathUtility;
66
use TYPO3\CMS\Core\Utility\StringUtility;
67
use TYPO3\CMS\Core\Versioning\VersionState;
68
69
/**
70
 * The main data handler class which takes care of correctly updating and inserting records.
71
 * This class was formerly known as TCEmain.
72
 *
73
 * This is the TYPO3 Core Engine class for manipulation of the database
74
 * This class is used by eg. the tce_db BE route (SimpleDataHandlerController) which provides an interface for POST forms to this class.
75
 *
76
 * Dependencies:
77
 * - $GLOBALS['TCA'] must exist
78
 * - $GLOBALS['LANG'] must exist
79
 *
80
 * Also see document 'TYPO3 Core API' for details.
81
 */
82
class DataHandler implements LoggerAwareInterface
83
{
84
    use LoggerAwareTrait;
85
86
    // *********************
87
    // Public variables you can configure before using the class:
88
    // *********************
89
    /**
90
     * If TRUE, the default log-messages will be stored. This should not be necessary if the locallang-file for the
91
     * log-display is properly configured. So disabling this will just save some database-space as the default messages are not saved.
92
     *
93
     * @var bool
94
     */
95
    public $storeLogMessages = true;
96
97
    /**
98
     * If TRUE, actions are logged to sys_log.
99
     *
100
     * @var bool
101
     */
102
    public $enableLogging = true;
103
104
    /**
105
     * If TRUE, the datamap array is reversed in the order, which is a nice thing if you're creating a whole new
106
     * bunch of records.
107
     *
108
     * @var bool
109
     */
110
    public $reverseOrder = false;
111
112
    /**
113
     * If TRUE, only fields which are different from the database values are saved! In fact, if a whole input array
114
     * is similar, it's not saved then.
115
     *
116
     * @var bool
117
     * @internal should only be used from within TYPO3 Core
118
     */
119
    public $checkSimilar = true;
120
121
    /**
122
     * This will read the record after having updated or inserted it. If anything is not properly submitted an error
123
     * is written to the log. This feature consumes extra time by selecting records
124
     *
125
     * @var bool
126
     */
127
    public $checkStoredRecords = true;
128
129
    /**
130
     * If set, values '' and 0 will equal each other when the stored records are checked.
131
     *
132
     * @var bool
133
     */
134
    public $checkStoredRecords_loose = true;
135
136
    /**
137
     * If set, then the 'hideAtCopy' flag for tables will be ignored.
138
     *
139
     * @var bool
140
     */
141
    public $neverHideAtCopy = false;
142
143
    /**
144
     * If set, then the TCE class has been instantiated during an import action of a T3D
145
     *
146
     * @var bool
147
     */
148
    public $isImporting = false;
149
150
    /**
151
     * If set, then transformations are NOT performed on the input.
152
     *
153
     * @var bool
154
     */
155
    public $dontProcessTransformations = false;
156
157
    /**
158
     * Will distinguish between translations (with parent) and localizations (without parent) while still using the same methods to copy the records
159
     * TRUE: translation of a record connected to the default language
160
     * FALSE: localization of a record without connection to the default language
161
     *
162
     * @var bool
163
     */
164
    protected $useTransOrigPointerField = true;
165
166
    /**
167
     * If TRUE, workspace restrictions are bypassed on edit and create actions (process_datamap()).
168
     * YOU MUST KNOW what you do if you use this feature!
169
     *
170
     * @var bool
171
     * @internal should only be used from within TYPO3 Core
172
     */
173
    public $bypassWorkspaceRestrictions = false;
174
175
    /**
176
     * If TRUE, access check, check for deleted etc. for records is bypassed.
177
     * YOU MUST KNOW what you are doing if you use this feature!
178
     *
179
     * @var bool
180
     */
181
    public $bypassAccessCheckForRecords = false;
182
183
    /**
184
     * Comma-separated list. This list of tables decides which tables will be copied. If empty then none will.
185
     * If '*' then all will (that the user has permission to of course)
186
     *
187
     * @var string
188
     * @internal should only be used from within TYPO3 Core
189
     */
190
    public $copyWhichTables = '*';
191
192
    /**
193
     * If 0 then branch is NOT copied.
194
     * If 1 then pages on the 1st level is copied.
195
     * If 2 then pages on the second level is copied ... and so on
196
     *
197
     * @var int
198
     */
199
    public $copyTree = 0;
200
201
    /**
202
     * [table][fields]=value: New records are created with default values and you can set this array on the
203
     * form $defaultValues[$table][$field] = $value to override the default values fetched from TCA.
204
     * If ->setDefaultsFromUserTS is called UserTSconfig default values will overrule existing values in this array
205
     * (thus UserTSconfig overrules externally set defaults which overrules TCA defaults)
206
     *
207
     * @var array
208
     * @internal should only be used from within TYPO3 Core
209
     */
210
    public $defaultValues = [];
211
212
    /**
213
     * [table][fields]=value: You can set this array on the form $overrideValues[$table][$field] = $value to
214
     * override the incoming data. You must set this externally. You must make sure the fields in this array are also
215
     * found in the table, because it's not checked. All columns can be set by this array!
216
     *
217
     * @var array
218
     * @internal should only be used from within TYPO3 Core
219
     */
220
    public $overrideValues = [];
221
222
    /**
223
     * If entries are set in this array corresponding to fields for update, they are ignored and thus NOT updated.
224
     * You could set this array from a series of checkboxes with value=0 and hidden fields before the checkbox with 1.
225
     * Then an empty checkbox will disable the field.
226
     *
227
     * @var array
228
     * @internal should only be used from within TYPO3 Core
229
     */
230
    public $data_disableFields = [];
231
232
    /**
233
     * Use this array to validate suggested uids for tables by setting [table]:[uid]. This is a dangerous option
234
     * since it will force the inserted record to have a certain UID. The value just have to be TRUE, but if you set
235
     * it to "DELETE" it will make sure any record with that UID will be deleted first (raw delete).
236
     * The option is used for import of T3D files when synchronizing between two mirrored servers.
237
     * As a security measure this feature is available only for Admin Users (for now)
238
     *
239
     * @var array
240
     */
241
    public $suggestedInsertUids = [];
242
243
    /**
244
     * Object. Call back object for FlexForm traversal. Useful when external classes wants to use the
245
     * iteration functions inside DataHandler for traversing a FlexForm structure.
246
     *
247
     * @var object
248
     * @internal should only be used from within TYPO3 Core
249
     */
250
    public $callBackObj;
251
252
    /**
253
     * A string which can be used as correlationId for RecordHistory entries.
254
     * The string can later be used to rollback multiple changes at once.
255
     *
256
     * @var CorrelationId|null
257
     */
258
    protected $correlationId;
259
260
    // *********************
261
    // Internal variables (mapping arrays) which can be used (read-only) from outside
262
    // *********************
263
    /**
264
     * Contains mapping of auto-versionized records.
265
     *
266
     * @var array<string, array<int, string>>
267
     * @internal should only be used from within TYPO3 Core
268
     */
269
    public $autoVersionIdMap = [];
270
271
    /**
272
     * When new elements are created, this array contains a map between their "NEW..." string IDs and the eventual UID they got when stored in database
273
     *
274
     * @var array
275
     */
276
    public $substNEWwithIDs = [];
277
278
    /**
279
     * Like $substNEWwithIDs, but where each old "NEW..." id is mapped to the table it was from.
280
     *
281
     * @var array
282
     * @internal should only be used from within TYPO3 Core
283
     */
284
    public $substNEWwithIDs_table = [];
285
286
    /**
287
     * Holds the tables and there the ids of newly created child records from IRRE
288
     *
289
     * @var array
290
     * @internal should only be used from within TYPO3 Core
291
     */
292
    public $newRelatedIDs = [];
293
294
    /**
295
     * This array is the sum of all copying operations in this class.
296
     *
297
     * @var array
298
     * @internal should only be used from within TYPO3 Core
299
     */
300
    public $copyMappingArray_merged = [];
301
302
    /**
303
     * Per-table array with UIDs that have been deleted.
304
     *
305
     * @var array
306
     */
307
    protected $deletedRecords = [];
308
309
    /**
310
     * Errors are collected in this variable.
311
     *
312
     * @var array
313
     * @internal should only be used from within TYPO3 Core
314
     */
315
    public $errorLog = [];
316
317
    /**
318
     * Fields from the pages-table for which changes will trigger a pagetree refresh
319
     *
320
     * @var array
321
     */
322
    public $pagetreeRefreshFieldsFromPages = ['pid', 'sorting', 'deleted', 'hidden', 'title', 'doktype', 'is_siteroot', 'fe_group', 'nav_hide', 'nav_title', 'module', 'starttime', 'endtime', 'content_from_pid', 'extendToSubpages'];
323
324
    /**
325
     * Indicates whether the pagetree needs a refresh because of important changes
326
     *
327
     * @var bool
328
     * @internal should only be used from within TYPO3 Core
329
     */
330
    public $pagetreeNeedsRefresh = false;
331
332
    // *********************
333
    // Internal Variables, do not touch.
334
    // *********************
335
336
    // Variables set in init() function:
337
338
    /**
339
     * The user-object the script uses. If not set from outside, this is set to the current global $BE_USER.
340
     *
341
     * @var BackendUserAuthentication
342
     */
343
    public $BE_USER;
344
345
    /**
346
     * Will be set to uid of be_user executing this script
347
     *
348
     * @var int
349
     * @internal should only be used from within TYPO3 Core
350
     */
351
    public $userid;
352
353
    /**
354
     * Will be set to username of be_user executing this script
355
     *
356
     * @var string
357
     * @internal should only be used from within TYPO3 Core
358
     */
359
    public $username;
360
361
    /**
362
     * Will be set if user is admin
363
     *
364
     * @var bool
365
     * @internal should only be used from within TYPO3 Core
366
     */
367
    public $admin;
368
369
    /**
370
     * @var PagePermissionAssembler
371
     */
372
    protected $pagePermissionAssembler;
373
374
    /**
375
     * The list of <table>-<fields> that cannot be edited by user. This is compiled from TCA/exclude-flag combined with non_exclude_fields for the user.
376
     *
377
     * @var array
378
     */
379
    protected $excludedTablesAndFields = [];
380
381
    /**
382
     * Data submitted from the form view, used to control behaviours,
383
     * e.g. this is used to activate/deactivate fields and thus store NULL values
384
     *
385
     * @var array
386
     */
387
    protected $control = [];
388
389
    /**
390
     * Set with incoming data array
391
     *
392
     * @var array<int|string, array<int|string, array>>
393
     */
394
    public $datamap = [];
395
396
    /**
397
     * Set with incoming cmd array
398
     *
399
     * @var array<string, array<int|string, array>>
400
     */
401
    public $cmdmap = [];
402
403
    /**
404
     * List of changed old record ids to new records ids
405
     *
406
     * @var array
407
     */
408
    protected $mmHistoryRecords = [];
409
410
    /**
411
     * List of changed old record ids to new records ids
412
     *
413
     * @var array
414
     */
415
    protected $historyRecords = [];
416
417
    // Internal static:
418
419
    /**
420
     * The interval between sorting numbers used with tables with a 'sorting' field defined.
421
     *
422
     * Min 1, should be power of 2
423
     *
424
     * @var int
425
     * @internal should only be used from within TYPO3 Core
426
     */
427
    public $sortIntervals = 256;
428
429
    // Internal caching arrays
430
    /**
431
     * User by function checkRecordInsertAccess() to store whether a record can be inserted on a page id
432
     *
433
     * @var array
434
     */
435
    protected $recInsertAccessCache = [];
436
437
    /**
438
     * Caching array for check of whether records are in a webmount
439
     *
440
     * @var array
441
     */
442
    protected $isRecordInWebMount_Cache = [];
443
444
    /**
445
     * Caching array for page ids in webmounts
446
     *
447
     * @var array
448
     */
449
    protected $isInWebMount_Cache = [];
450
451
    /**
452
     * Used for caching page records in pageInfo()
453
     *
454
     * @var array<int, array<string, array>>
455
     */
456
    protected $pageCache = [];
457
458
    // Other arrays:
459
    /**
460
     * For accumulation of MM relations that must be written after new records are created.
461
     *
462
     * @var array
463
     * @internal
464
     */
465
    public $dbAnalysisStore = [];
466
467
    /**
468
     * Used for tracking references that might need correction after operations
469
     *
470
     * @var array<string, array<int, array>>
471
     * @internal
472
     */
473
    public $registerDBList = [];
474
475
    /**
476
     * Used for tracking references that might need correction in pid field after operations (e.g. IRRE)
477
     *
478
     * @var array
479
     * @internal
480
     */
481
    public $registerDBPids = [];
482
483
    /**
484
     * Used by the copy action to track the ids of new pages so subpages are correctly inserted!
485
     * THIS is internally cleared for each executed copy operation! DO NOT USE THIS FROM OUTSIDE!
486
     * Read from copyMappingArray_merged instead which is accumulating this information.
487
     *
488
     * NOTE: This is used by some outside scripts (e.g. hooks), as the results in $copyMappingArray_merged
489
     * are only available after an action has been completed.
490
     *
491
     * @var array<string, array>
492
     * @internal
493
     */
494
    public $copyMappingArray = [];
495
496
    /**
497
     * Array used for remapping uids and values at the end of process_datamap
498
     *
499
     * @var array
500
     * @internal
501
     */
502
    public $remapStack = [];
503
504
    /**
505
     * Array used for remapping uids and values at the end of process_datamap
506
     * (e.g. $remapStackRecords[<table>][<uid>] = <index in $remapStack>)
507
     *
508
     * @var array
509
     * @internal
510
     */
511
    public $remapStackRecords = [];
512
513
    /**
514
     * Array used for checking whether new children need to be remapped
515
     *
516
     * @var array
517
     */
518
    protected $remapStackChildIds = [];
519
520
    /**
521
     * Array used for executing addition actions after remapping happened (set processRemapStack())
522
     *
523
     * @var array
524
     */
525
    protected $remapStackActions = [];
526
527
    /**
528
     * Registry object to gather reference index update requests and perform updates after
529
     * main processing has been done. The first call to start() instantiates this object.
530
     * Recursive sub instances receive this instance via __construct().
531
     * The final update() call is done at the end of process_cmdmap() or process_datamap()
532
     * in the outer most instance.
533
     *
534
     * @var ReferenceIndexUpdater
535
     */
536
    protected $referenceIndexUpdater;
537
538
    /**
539
     * Tells, that this DataHandler instance was called from \TYPO3\CMS\Impext\ImportExport.
540
     * This variable is set by \TYPO3\CMS\Impext\ImportExport
541
     *
542
     * @var bool
543
     * @internal only used within TYPO3 Core
544
     */
545
    public $callFromImpExp = false;
546
547
    // Various
548
549
    /**
550
     * Set to "currentRecord" during checking of values.
551
     *
552
     * @var array
553
     * @internal
554
     */
555
    public $checkValue_currentRecord = [];
556
557
    /**
558
     * Disable delete clause
559
     *
560
     * @var bool
561
     */
562
    protected $disableDeleteClause = false;
563
564
    /**
565
     * @var array
566
     */
567
    protected $checkModifyAccessListHookObjects;
568
569
    /**
570
     * @var array
571
     */
572
    protected $version_remapMMForVersionSwap_reg;
573
574
    /**
575
     * The outer most instance of \TYPO3\CMS\Core\DataHandling\DataHandler:
576
     * This object instantiates itself on versioning and localization ...
577
     *
578
     * @var \TYPO3\CMS\Core\DataHandling\DataHandler
579
     */
580
    protected $outerMostInstance;
581
582
    /**
583
     * Internal cache for collecting records that should trigger cache clearing
584
     *
585
     * @var array
586
     */
587
    protected static $recordsToClearCacheFor = [];
588
589
    /**
590
     * Internal cache for pids of records which were deleted. It's not possible
591
     * to retrieve the parent folder/page at a later stage
592
     *
593
     * @var array
594
     */
595
    protected static $recordPidsForDeletedRecords = [];
596
597
    /**
598
     * Runtime Cache to store and retrieve data computed for a single request
599
     *
600
     * @var \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
601
     */
602
    protected $runtimeCache;
603
604
    /**
605
     * Prefix for the cache entries of nested element calls since the runtimeCache has a global scope.
606
     *
607
     * @var string
608
     */
609
    protected $cachePrefixNestedElementCalls = 'core-datahandler-nestedElementCalls-';
610
611
    /**
612
     * Sets up the data handler cache and some additional options, the main logic is done in the start() method.
613
     *
614
     * @param ReferenceIndexUpdater|null $referenceIndexUpdater Hand over from outer most instance to sub instances
615
     */
616
    public function __construct(ReferenceIndexUpdater $referenceIndexUpdater = null)
617
    {
618
        $this->checkStoredRecords = (bool)$GLOBALS['TYPO3_CONF_VARS']['BE']['checkStoredRecords'];
619
        $this->checkStoredRecords_loose = (bool)$GLOBALS['TYPO3_CONF_VARS']['BE']['checkStoredRecordsLoose'];
620
        $this->runtimeCache = $this->getRuntimeCache();
621
        $this->pagePermissionAssembler = GeneralUtility::makeInstance(PagePermissionAssembler::class, $GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']);
622
        if ($referenceIndexUpdater === null) {
623
            // Create ReferenceIndexUpdater object. This should only happen on outer most instance,
624
            // sub instances should receive the reference index updater from a parent.
625
            $referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class);
626
        }
627
        $this->referenceIndexUpdater = $referenceIndexUpdater;
628
    }
629
630
    /**
631
     * @param array $control
632
     * @internal
633
     */
634
    public function setControl(array $control)
635
    {
636
        $this->control = $control;
637
    }
638
639
    /**
640
     * Initializing.
641
     * For details, see 'TYPO3 Core API' document.
642
     * This function does not start the processing of data, but merely initializes the object
643
     *
644
     * @param array $data Data to be modified or inserted in the database
645
     * @param array $cmd Commands to copy, move, delete, localize, versionize records.
646
     * @param BackendUserAuthentication|null $altUserObject An alternative userobject you can set instead of the default, which is $GLOBALS['BE_USER']
647
     */
648
    public function start($data, $cmd, $altUserObject = null)
649
    {
650
        // Initializing BE_USER
651
        $this->BE_USER = is_object($altUserObject) ? $altUserObject : $GLOBALS['BE_USER'];
652
        $this->userid = $this->BE_USER->user['uid'] ?? 0;
653
        $this->username = $this->BE_USER->user['username'] ?? '';
654
        $this->admin = $this->BE_USER->user['admin'] ?? false;
655
656
        // set correlation id for each new set of data or commands
657
        $this->correlationId = CorrelationId::forScope(
658
            md5(StringUtility::getUniqueId(self::class))
659
        );
660
661
        // Get default values from user TSconfig
662
        $tcaDefaultOverride = $this->BE_USER->getTSConfig()['TCAdefaults.'] ?? null;
663
        if (is_array($tcaDefaultOverride)) {
664
            $this->setDefaultsFromUserTS($tcaDefaultOverride);
665
        }
666
667
        // generates the excludelist, based on TCA/exclude-flag and non_exclude_fields for the user:
668
        if (!$this->admin) {
669
            $this->excludedTablesAndFields = array_flip($this->getExcludeListArray());
670
        }
671
        // Setting the data and cmd arrays
672
        if (is_array($data)) {
0 ignored issues
show
introduced by
The condition is_array($data) is always true.
Loading history...
673
            reset($data);
674
            $this->datamap = $data;
675
        }
676
        if (is_array($cmd)) {
0 ignored issues
show
introduced by
The condition is_array($cmd) is always true.
Loading history...
677
            reset($cmd);
678
            $this->cmdmap = $cmd;
679
        }
680
    }
681
682
    /**
683
     * Function that can mirror input values in datamap-array to other uid numbers.
684
     * Example: $mirror[table][11] = '22,33' will look for content in $this->datamap[table][11] and copy it to $this->datamap[table][22] and $this->datamap[table][33]
685
     *
686
     * @param array $mirror This array has the syntax $mirror[table_name][uid] = [list of uids to copy data-value TO!]
687
     * @internal
688
     */
689
    public function setMirror($mirror)
690
    {
691
        if (!is_array($mirror)) {
0 ignored issues
show
introduced by
The condition is_array($mirror) is always true.
Loading history...
692
            return;
693
        }
694
695
        foreach ($mirror as $table => $uid_array) {
696
            if (!isset($this->datamap[$table])) {
697
                continue;
698
            }
699
700
            foreach ($uid_array as $id => $uidList) {
701
                if (!isset($this->datamap[$table][$id])) {
702
                    continue;
703
                }
704
705
                $theIdsInArray = GeneralUtility::trimExplode(',', $uidList, true);
706
                foreach ($theIdsInArray as $copyToUid) {
707
                    $this->datamap[$table][$copyToUid] = $this->datamap[$table][$id];
708
                }
709
            }
710
        }
711
    }
712
713
    /**
714
     * Initializes default values coming from User TSconfig
715
     *
716
     * @param array $userTS User TSconfig array
717
     * @internal should only be used from within DataHandler
718
     */
719
    public function setDefaultsFromUserTS($userTS)
720
    {
721
        if (!is_array($userTS)) {
0 ignored issues
show
introduced by
The condition is_array($userTS) is always true.
Loading history...
722
            return;
723
        }
724
725
        foreach ($userTS as $k => $v) {
726
            $k = mb_substr($k, 0, -1);
727
            if (!$k || !is_array($v) || !isset($GLOBALS['TCA'][$k])) {
728
                continue;
729
            }
730
731
            if (is_array($this->defaultValues[$k] ?? false)) {
732
                $this->defaultValues[$k] = array_merge($this->defaultValues[$k], $v);
733
            } else {
734
                $this->defaultValues[$k] = $v;
735
            }
736
        }
737
    }
738
739
    /**
740
     * When a new record is created, all values that haven't been set but are set via PageTSconfig / UserTSconfig
741
     * get applied here.
742
     *
743
     * This is only executed for new records. The most important part is that the pageTS of the actual resolved $pid
744
     * is taken, and a new field array with empty defaults is set again.
745
     *
746
     * @param string $table
747
     * @param int $pageId
748
     * @param array $prepopulatedFieldArray
749
     * @return array
750
     */
751
    protected function applyDefaultsForFieldArray(string $table, int $pageId, array $prepopulatedFieldArray): array
752
    {
753
        // First set TCAdefaults respecting the given PageID
754
        $tcaDefaults = BackendUtility::getPagesTSconfig($pageId)['TCAdefaults.'] ?? null;
755
        // Re-apply $this->defaultValues settings
756
        $this->setDefaultsFromUserTS($tcaDefaults);
757
        $cleanFieldArray = $this->newFieldArray($table);
758
        if (isset($prepopulatedFieldArray['pid'])) {
759
            $cleanFieldArray['pid'] = $prepopulatedFieldArray['pid'];
760
        }
761
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? null;
762
        if ($sortColumn !== null && isset($prepopulatedFieldArray[$sortColumn])) {
763
            $cleanFieldArray[$sortColumn] = $prepopulatedFieldArray[$sortColumn];
764
        }
765
        return $cleanFieldArray;
766
    }
767
768
    /*********************************************
769
     *
770
     * HOOKS
771
     *
772
     *********************************************/
773
    /**
774
     * Hook: processDatamap_afterDatabaseOperations
775
     * (calls $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);)
776
     *
777
     * Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
778
     * but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
779
     *
780
     * @param array $hookObjectsArr (reference) Array with hook objects
781
     * @param string $status (reference) Status of the current operation, 'new' or 'update
782
     * @param string $table (reference) The table currently processing data for
783
     * @param string $id (reference) The record uid currently processing data for, [integer] or [string] (like 'NEW...')
784
     * @param array $fieldArray (reference) The field array of a record
785
     * @internal should only be used from within DataHandler
786
     */
787
    public function hook_processDatamap_afterDatabaseOperations(&$hookObjectsArr, &$status, &$table, &$id, &$fieldArray)
788
    {
789
        // Process hook directly:
790
        if (!isset($this->remapStackRecords[$table][$id])) {
791
            foreach ($hookObjectsArr as $hookObj) {
792
                if (method_exists($hookObj, 'processDatamap_afterDatabaseOperations')) {
793
                    $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);
794
                }
795
            }
796
        } else {
797
            $this->remapStackRecords[$table][$id]['processDatamap_afterDatabaseOperations'] = [
798
                'status' => $status,
799
                'fieldArray' => $fieldArray,
800
                'hookObjectsArr' => $hookObjectsArr
801
            ];
802
        }
803
    }
804
805
    /**
806
     * Gets the 'checkModifyAccessList' hook objects.
807
     * The first call initializes the accordant objects.
808
     *
809
     * @return array The 'checkModifyAccessList' hook objects (if any)
810
     * @throws \UnexpectedValueException
811
     */
812
    protected function getCheckModifyAccessListHookObjects()
813
    {
814
        if (!isset($this->checkModifyAccessListHookObjects)) {
815
            $this->checkModifyAccessListHookObjects = [];
816
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkModifyAccessList'] ?? [] as $className) {
817
                $hookObject = GeneralUtility::makeInstance($className);
818
                if (!$hookObject instanceof DataHandlerCheckModifyAccessListHookInterface) {
819
                    throw new \UnexpectedValueException($className . ' must implement interface ' . DataHandlerCheckModifyAccessListHookInterface::class, 1251892472);
820
                }
821
                $this->checkModifyAccessListHookObjects[] = $hookObject;
822
            }
823
        }
824
        return $this->checkModifyAccessListHookObjects;
825
    }
826
827
    /*********************************************
828
     *
829
     * PROCESSING DATA
830
     *
831
     *********************************************/
832
    /**
833
     * Processing the data-array
834
     * Call this function to process the data-array set by start()
835
     *
836
     * @return bool|void
837
     */
838
    public function process_datamap()
839
    {
840
        $this->controlActiveElements();
841
842
        // Keep versionized(!) relations here locally:
843
        $registerDBList = [];
844
        $this->registerElementsToBeDeleted();
845
        $this->datamap = $this->unsetElementsToBeDeleted($this->datamap);
846
        // Editing frozen:
847
        if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
848
            $this->newlog('All editing in this workspace has been frozen!', SystemLogErrorClassification::USER_ERROR);
849
            return false;
850
        }
851
        // First prepare user defined objects (if any) for hooks which extend this function:
852
        $hookObjectsArr = [];
853
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'] ?? [] as $className) {
854
            $hookObject = GeneralUtility::makeInstance($className);
855
            if (method_exists($hookObject, 'processDatamap_beforeStart')) {
856
                $hookObject->processDatamap_beforeStart($this);
857
            }
858
            $hookObjectsArr[] = $hookObject;
859
        }
860
        // Pre-process data-map and synchronize localization states
861
        $this->datamap = GeneralUtility::makeInstance(SlugEnricher::class)->enrichDataMap($this->datamap);
862
        $this->datamap = DataMapProcessor::instance($this->datamap, $this->BE_USER, $this->referenceIndexUpdater)->process();
863
        // Organize tables so that the pages-table is always processed first. This is required if you want to make sure that content pointing to a new page will be created.
864
        $orderOfTables = [];
865
        // Set pages first.
866
        if (isset($this->datamap['pages'])) {
867
            $orderOfTables[] = 'pages';
868
        }
869
        $orderOfTables = array_unique(array_merge($orderOfTables, array_keys($this->datamap)));
870
        // Process the tables...
871
        foreach ($orderOfTables as $table) {
872
            // Check if
873
            //	   - table is set in $GLOBALS['TCA'],
874
            //	   - table is NOT readOnly
875
            //	   - the table is set with content in the data-array (if not, there's nothing to process...)
876
            //	   - permissions for tableaccess OK
877
            $modifyAccessList = $this->checkModifyAccessList($table);
878
            if (!$modifyAccessList) {
879
                $this->log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table \'%s\' without permission', 1, [$table]);
880
            }
881
            if (!isset($GLOBALS['TCA'][$table]) || $this->tableReadOnly($table) || !is_array($this->datamap[$table]) || !$modifyAccessList) {
882
                continue;
883
            }
884
885
            if ($this->reverseOrder) {
886
                $this->datamap[$table] = array_reverse($this->datamap[$table], true);
887
            }
888
            // For each record from the table, do:
889
            // $id is the record uid, may be a string if new records...
890
            // $incomingFieldArray is the array of fields
891
            foreach ($this->datamap[$table] as $id => $incomingFieldArray) {
892
                if (!is_array($incomingFieldArray)) {
893
                    continue;
894
                }
895
                $theRealPid = null;
896
897
                // Hook: processDatamap_preProcessFieldArray
898
                foreach ($hookObjectsArr as $hookObj) {
899
                    if (method_exists($hookObj, 'processDatamap_preProcessFieldArray')) {
900
                        $hookObj->processDatamap_preProcessFieldArray($incomingFieldArray, $table, $id, $this);
901
                    }
902
                }
903
                // ******************************
904
                // Checking access to the record
905
                // ******************************
906
                $createNewVersion = false;
907
                $recordAccess = false;
908
                $old_pid_value = '';
909
                // Is it a new record? (Then Id is a string)
910
                if (!MathUtility::canBeInterpretedAsInteger($id)) {
911
                    // Get a fieldArray with tca default values
912
                    $fieldArray = $this->newFieldArray($table);
913
                    // A pid must be set for new records.
914
                    if (isset($incomingFieldArray['pid'])) {
915
                        $pid_value = $incomingFieldArray['pid'];
916
                        // Checking and finding numerical pid, it may be a string-reference to another value
917
                        $canProceed = true;
918
                        // If a NEW... id
919
                        if (strpos($pid_value, 'NEW') !== false) {
920
                            if ($pid_value[0] === '-') {
921
                                $negFlag = -1;
922
                                $pid_value = substr($pid_value, 1);
923
                            } else {
924
                                $negFlag = 1;
925
                            }
926
                            // Trying to find the correct numerical value as it should be mapped by earlier processing of another new record.
927
                            if (isset($this->substNEWwithIDs[$pid_value])) {
928
                                if ($negFlag === 1) {
929
                                    $old_pid_value = $this->substNEWwithIDs[$pid_value];
930
                                }
931
                                $pid_value = (int)($negFlag * $this->substNEWwithIDs[$pid_value]);
932
                            } else {
933
                                $canProceed = false;
934
                            }
935
                        }
936
                        $pid_value = (int)$pid_value;
937
                        if ($canProceed) {
938
                            $fieldArray = $this->resolveSortingAndPidForNewRecord($table, $pid_value, $fieldArray);
939
                        }
940
                    }
941
                    $theRealPid = $fieldArray['pid'];
942
                    // Checks if records can be inserted on this $pid.
943
                    // If this is a page translation, the check needs to be done for the l10n_parent record
944
                    $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
945
                    $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
946
                    if ($table === 'pages'
947
                        && $languageField && isset($incomingFieldArray[$languageField]) && $incomingFieldArray[$languageField] > 0
948
                        && $transOrigPointerField && isset($incomingFieldArray[$transOrigPointerField]) && $incomingFieldArray[$transOrigPointerField] > 0
949
                    ) {
950
                        $recordAccess = $this->checkRecordInsertAccess($table, $incomingFieldArray[$transOrigPointerField]);
951
                    } else {
952
                        $recordAccess = $this->checkRecordInsertAccess($table, $theRealPid);
953
                    }
954
                    if ($recordAccess) {
955
                        $this->addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray);
956
                        $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $incomingFieldArray, true);
957
                        if (!$recordAccess) {
958
                            $this->newlog('recordEditAccessInternals() check failed. [' . $this->BE_USER->errorMsg . ']', SystemLogErrorClassification::USER_ERROR);
959
                        } elseif (!$this->bypassWorkspaceRestrictions && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
960
                            // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record
961
                            // So, if no live records were allowed in the current workspace, we have to create a new version of this record
962
                            if (BackendUtility::isTableWorkspaceEnabled($table)) {
963
                                $createNewVersion = true;
964
                            } else {
965
                                $recordAccess = false;
966
                                $this->newlog('Record could not be created in this workspace', SystemLogErrorClassification::USER_ERROR);
967
                            }
968
                        }
969
                    }
970
                    // Yes new record, change $record_status to 'insert'
971
                    $status = 'new';
972
                } else {
973
                    // Nope... $id is a number
974
                    $id = (int)$id;
975
                    $fieldArray = [];
976
                    $recordAccess = $this->checkRecordUpdateAccess($table, $id, $incomingFieldArray, $hookObjectsArr);
977
                    if (!$recordAccess) {
978
                        if ($this->enableLogging) {
979
                            $propArr = $this->getRecordProperties($table, $id);
980
                            $this->log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify record \'%s\' (%s) without permission. Or non-existing page.', 2, [$propArr['header'], $table . ':' . $id], $propArr['event_pid']);
981
                        }
982
                        continue;
983
                    }
984
                    // Next check of the record permissions (internals)
985
                    $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $id);
986
                    if (!$recordAccess) {
987
                        $this->newlog('recordEditAccessInternals() check failed. [' . $this->BE_USER->errorMsg . ']', SystemLogErrorClassification::USER_ERROR);
988
                    } else {
989
                        // Here we fetch the PID of the record that we point to...
990
                        $tempdata = $this->recordInfo($table, $id, 'pid' . (BackendUtility::isTableWorkspaceEnabled($table) ? ',t3ver_oid,t3ver_wsid,t3ver_stage' : ''));
991
                        $theRealPid = $tempdata['pid'] ?? null;
992
                        // Use the new id of the versionized record we're trying to write to:
993
                        // (This record is a child record of a parent and has already been versionized.)
994
                        if (!empty($this->autoVersionIdMap[$table][$id])) {
995
                            // For the reason that creating a new version of this record, automatically
996
                            // created related child records (e.g. "IRRE"), update the accordant field:
997
                            $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
998
                            // Use the new id of the copied/versionized record:
999
                            $id = $this->autoVersionIdMap[$table][$id];
1000
                            $recordAccess = true;
1001
                        } elseif (!$this->bypassWorkspaceRestrictions && ($errorCode = $this->BE_USER->workspaceCannotEditRecord($table, $tempdata))) {
1002
                            $recordAccess = false;
1003
                            // Versioning is required and it must be offline version!
1004
                            // Check if there already is a workspace version
1005
                            $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid,t3ver_oid');
1006
                            if ($workspaceVersion) {
1007
                                $id = $workspaceVersion['uid'];
1008
                                $recordAccess = true;
1009
                            } elseif ($this->BE_USER->workspaceAllowAutoCreation($table, $id, $theRealPid)) {
1010
                                // new version of a record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1011
                                $this->pagetreeNeedsRefresh = true;
1012
1013
                                /** @var DataHandler $tce */
1014
                                $tce = GeneralUtility::makeInstance(__CLASS__, $this->referenceIndexUpdater);
1015
                                $tce->enableLogging = $this->enableLogging;
1016
                                // Setting up command for creating a new version of the record:
1017
                                $cmd = [];
1018
                                $cmd[$table][$id]['version'] = [
1019
                                    'action' => 'new',
1020
                                    // Default is to create a version of the individual records
1021
                                    'label' => 'Auto-created for WS #' . $this->BE_USER->workspace
1022
                                ];
1023
                                $tce->start([], $cmd, $this->BE_USER);
1024
                                $tce->process_cmdmap();
1025
                                $this->errorLog = array_merge($this->errorLog, $tce->errorLog);
1026
                                // If copying was successful, share the new uids (also of related children):
1027
                                if (!empty($tce->copyMappingArray[$table][$id])) {
1028
                                    foreach ($tce->copyMappingArray as $origTable => $origIdArray) {
1029
                                        foreach ($origIdArray as $origId => $newId) {
1030
                                            $this->autoVersionIdMap[$origTable][$origId] = $newId;
1031
                                        }
1032
                                    }
1033
                                    // Update registerDBList, that holds the copied relations to child records:
1034
                                    $registerDBList = array_merge($registerDBList, $tce->registerDBList);
1035
                                    // For the reason that creating a new version of this record, automatically
1036
                                    // created related child records (e.g. "IRRE"), update the accordant field:
1037
                                    $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
1038
                                    // Use the new id of the copied/versionized record:
1039
                                    $id = $this->autoVersionIdMap[$table][$id];
1040
                                    $recordAccess = true;
1041
                                } else {
1042
                                    $this->newlog('Could not be edited in offline workspace in the branch where found (failure state: \'' . $errorCode . '\'). Auto-creation of version failed!', SystemLogErrorClassification::USER_ERROR);
1043
                                }
1044
                            } else {
1045
                                $this->newlog('Could not be edited in offline workspace in the branch where found (failure state: \'' . $errorCode . '\'). Auto-creation of version not allowed in workspace!', SystemLogErrorClassification::USER_ERROR);
1046
                            }
1047
                        }
1048
                    }
1049
                    // The default is 'update'
1050
                    $status = 'update';
1051
                }
1052
                // If access was granted above, proceed to create or update record:
1053
                if (!$recordAccess) {
1054
                    continue;
1055
                }
1056
1057
                // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array.
1058
                [$tscPID] = BackendUtility::getTSCpid($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0));
1059
                if ($status === 'new') {
1060
                    // Apply TCAdefaults from pageTS
1061
                    $fieldArray = $this->applyDefaultsForFieldArray($table, (int)$tscPID, $fieldArray);
1062
                    // Apply page permissions as well
1063
                    if ($table === 'pages') {
1064
                        $fieldArray = $this->pagePermissionAssembler->applyDefaults(
1065
                            $fieldArray,
1066
                            (int)$tscPID,
1067
                            (int)$this->userid,
1068
                            (int)$this->BE_USER->firstMainGroup
1069
                        );
1070
                    }
1071
                    // Ensure that the default values, that are stored in the $fieldArray (built from internal default values)
1072
                    // Are also placed inside the incomingFieldArray, so this is checked in "fillInFieldArray" and
1073
                    // all default values are also checked for validity
1074
                    // This allows to set TCAdefaults (for example) without having to use FormEngine to have the fields available first.
1075
                    $incomingFieldArray = array_replace_recursive($fieldArray, $incomingFieldArray);
1076
                }
1077
                // Processing of all fields in incomingFieldArray and setting them in $fieldArray
1078
                $fieldArray = $this->fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
1079
                // NOTICE! All manipulation beyond this point bypasses both "excludeFields" AND possible "MM" relations to field!
1080
                // Forcing some values unto field array:
1081
                // NOTICE: This overriding is potentially dangerous; permissions per field is not checked!!!
1082
                $fieldArray = $this->overrideFieldArray($table, $fieldArray);
1083
                // Setting system fields
1084
                if ($status === 'new') {
1085
                    if ($GLOBALS['TCA'][$table]['ctrl']['crdate']) {
1086
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
1087
                    }
1088
                    if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id']) {
1089
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
1090
                    }
1091
                } elseif ($this->checkSimilar) {
1092
                    // Removing fields which are equal to the current value:
1093
                    $fieldArray = $this->compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray);
1094
                }
1095
                if ($GLOBALS['TCA'][$table]['ctrl']['tstamp'] && !empty($fieldArray)) {
1096
                    $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
1097
                }
1098
                // Set stage to "Editing" to make sure we restart the workflow
1099
                if (BackendUtility::isTableWorkspaceEnabled($table)) {
1100
                    $fieldArray['t3ver_stage'] = 0;
1101
                }
1102
                // Hook: processDatamap_postProcessFieldArray
1103
                foreach ($hookObjectsArr as $hookObj) {
1104
                    if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) {
1105
                        $hookObj->processDatamap_postProcessFieldArray($status, $table, $id, $fieldArray, $this);
1106
                    }
1107
                }
1108
                // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything
1109
                // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already
1110
                if (is_array($fieldArray)) {
1111
                    if ($status === 'new') {
1112
                        if ($table === 'pages') {
1113
                            // for new pages always a refresh is needed
1114
                            $this->pagetreeNeedsRefresh = true;
1115
                        }
1116
1117
                        // This creates a version of the record, instead of adding it to the live workspace
1118
                        if ($createNewVersion) {
1119
                            // new record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1120
                            $this->pagetreeNeedsRefresh = true;
1121
                            $fieldArray['pid'] = $theRealPid;
1122
                            $fieldArray['t3ver_oid'] = 0;
1123
                            // Setting state for version (so it can know it is currently a new version...)
1124
                            $fieldArray['t3ver_state'] = (string)new VersionState(VersionState::NEW_PLACEHOLDER);
1125
                            $fieldArray['t3ver_wsid'] = $this->BE_USER->workspace;
1126
                            $this->insertDB($table, $id, $fieldArray, true, (int)($incomingFieldArray['uid'] ?? 0));
1127
                            // Hold auto-versionized ids of placeholders
1128
                            $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id];
1129
                        } else {
1130
                            $this->insertDB($table, $id, $fieldArray, false, (int)($incomingFieldArray['uid'] ?? 0));
1131
                        }
1132
                    } else {
1133
                        if ($table === 'pages') {
1134
                            // only a certain number of fields needs to be checked for updates
1135
                            // if $this->checkSimilar is TRUE, fields with unchanged values are already removed here
1136
                            $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray));
1137
                            if (!empty($fieldsToCheck)) {
1138
                                $this->pagetreeNeedsRefresh = true;
1139
                            }
1140
                        }
1141
                        $this->updateDB($table, $id, $fieldArray);
1142
                    }
1143
                }
1144
                // Hook: processDatamap_afterDatabaseOperations
1145
                // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
1146
                // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
1147
                $this->hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, $table, $id, $fieldArray);
1148
            }
1149
        }
1150
        // Process the stack of relations to remap/correct
1151
        $this->processRemapStack();
1152
        $this->dbAnalysisStoreExec();
1153
        // Hook: processDatamap_afterAllOperations
1154
        // Note: When this hook gets called, all operations on the submitted data have been finished.
1155
        foreach ($hookObjectsArr as $hookObj) {
1156
            if (method_exists($hookObj, 'processDatamap_afterAllOperations')) {
1157
                $hookObj->processDatamap_afterAllOperations($this);
1158
            }
1159
        }
1160
1161
        if ($this->isOuterMostInstance()) {
1162
            $this->referenceIndexUpdater->update();
1163
            $this->processClearCacheQueue();
1164
            $this->resetElementsToBeDeleted();
1165
        }
1166
    }
1167
1168
    /**
1169
     * @param string $table
1170
     * @param string $value
1171
     * @param string $dbType
1172
     * @return string
1173
     */
1174
    protected function normalizeTimeFormat(string $table, string $value, string $dbType): string
1175
    {
1176
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
1177
        $platform = $connection->getDatabasePlatform();
1178
        if ($platform instanceof SQLServerPlatform) {
1179
            $defaultLength = QueryHelper::getDateTimeFormats()[$dbType]['empty'];
1180
            $value = substr(
1181
                $value,
1182
                0,
1183
                strlen($defaultLength)
1184
            );
1185
        }
1186
        return $value;
1187
    }
1188
1189
    /**
1190
     * Sets the "sorting" DB field and the "pid" field of an incoming record that should be added (NEW1234)
1191
     * depending on the record that should be added or where it should be added.
1192
     *
1193
     * This method is called from process_datamap()
1194
     *
1195
     * @param string $table the table name of the record to insert
1196
     * @param int $pid the real PID (numeric) where the record should be
1197
     * @param array $fieldArray field+value pairs to add
1198
     * @return array the modified field array
1199
     */
1200
    protected function resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array
1201
    {
1202
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
1203
        // Points to a page on which to insert the element, possibly in the top of the page
1204
        if ($pid >= 0) {
1205
            // Ensure that the "pid" is not a translated page ID, but the default page ID
1206
            $pid = $this->getDefaultLanguagePageId($pid);
1207
            // The numerical pid is inserted in the data array
1208
            $fieldArray['pid'] = $pid;
1209
            // If this table is sorted we better find the top sorting number
1210
            if ($sortColumn) {
1211
                $fieldArray[$sortColumn] = $this->getSortNumber($table, 0, $pid);
1212
            }
1213
        } elseif ($sortColumn) {
1214
            // Points to another record before itself
1215
            // If this table is sorted we better find the top sorting number
1216
            // Because $pid is < 0, getSortNumber() returns an array
1217
            $sortingInfo = $this->getSortNumber($table, 0, $pid);
1218
            $fieldArray['pid'] = $sortingInfo['pid'];
1219
            $fieldArray[$sortColumn] = $sortingInfo['sortNumber'];
1220
        } else {
1221
            // Here we fetch the PID of the record that we point to
1222
            $record = $this->recordInfo($table, abs($pid), 'pid');
0 ignored issues
show
Bug introduced by
It seems like abs($pid) can also be of type double; however, parameter $id of TYPO3\CMS\Core\DataHandl...taHandler::recordInfo() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1222
            $record = $this->recordInfo($table, /** @scrutinizer ignore-type */ abs($pid), 'pid');
Loading history...
1223
            // Ensure that the "pid" is not a translated page ID, but the default page ID
1224
            $fieldArray['pid'] = $this->getDefaultLanguagePageId($record['pid']);
1225
        }
1226
        return $fieldArray;
1227
    }
1228
1229
    /**
1230
     * Filling in the field array
1231
     * $this->excludedTablesAndFields is used to filter fields if needed.
1232
     *
1233
     * @param string $table Table name
1234
     * @param int|string $id Record ID
1235
     * @param array $fieldArray Default values, Preset $fieldArray with 'pid' maybe (pid and uid will be not be overridden anyway)
1236
     * @param array $incomingFieldArray Is which fields/values you want to set. There are processed and put into $fieldArray if OK
1237
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1238
     * @param string $status Is 'new' or 'update'
1239
     * @param int $tscPID TSconfig PID
1240
     * @return array Field Array
1241
     * @internal should only be used from within DataHandler
1242
     */
1243
    public function fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $realPid, $status, $tscPID)
1244
    {
1245
        // Initialize:
1246
        $originalLanguageRecord = null;
1247
        $originalLanguage_diffStorage = null;
1248
        $diffStorageFlag = false;
1249
        // Setting 'currentRecord' and 'checkValueRecord':
1250
        if (strpos((string)$id, 'NEW') !== false) {
1251
            // Must have the 'current' array - not the values after processing below...
1252
            $checkValueRecord = $fieldArray;
1253
            // IF $incomingFieldArray is an array, overlay it.
1254
            // The point is that when new records are created as copies with flex type fields there might be a field containing information about which DataStructure to use and without that information the flexforms cannot be correctly processed.... This should be OK since the $checkValueRecord is used by the flexform evaluation only anyways...
1255
            if (is_array($incomingFieldArray) && is_array($checkValueRecord)) {
0 ignored issues
show
introduced by
The condition is_array($checkValueRecord) is always true.
Loading history...
1256
                ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray);
1257
            }
1258
            $currentRecord = $checkValueRecord;
1259
        } else {
1260
            $id = (int)$id;
1261
            // We must use the current values as basis for this!
1262
            $currentRecord = ($checkValueRecord = $this->recordInfo($table, $id, '*'));
1263
        }
1264
1265
        // Get original language record if available:
1266
        if (is_array($currentRecord)
1267
            && ($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false)
1268
            && !empty($GLOBALS['TCA'][$table]['ctrl']['languageField'])
1269
            && $currentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0
1270
            && $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']
1271
            && (int)$currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] > 0
1272
        ) {
1273
            $originalLanguageRecord = $this->recordInfo($table, $currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']], '*');
1274
            BackendUtility::workspaceOL($table, $originalLanguageRecord);
1275
            $originalLanguage_diffStorage = json_decode(
1276
                (string)($currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] ?? ''),
1277
                true
1278
            );
1279
        }
1280
1281
        $this->checkValue_currentRecord = $checkValueRecord;
1282
        // In the following all incoming value-fields are tested:
1283
        // - Are the user allowed to change the field?
1284
        // - Is the field uid/pid (which are already set)
1285
        // - perms-fields for pages-table, then do special things...
1286
        // - If the field is nothing of the above and the field is configured in TCA, the fieldvalues are evaluated by ->checkValue
1287
        // If everything is OK, the field is entered into $fieldArray[]
1288
        foreach ($incomingFieldArray as $field => $fieldValue) {
1289
            if (isset($this->excludedTablesAndFields[$table . '-' . $field]) || (bool)($this->data_disableFields[$table][$id][$field] ?? false)) {
1290
                continue;
1291
            }
1292
1293
            // The field must be editable.
1294
            // Checking if a value for language can be changed:
1295
            if (($GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false)
1296
                && (string)$GLOBALS['TCA'][$table]['ctrl']['languageField'] === (string)$field
1297
                && !$this->BE_USER->checkLanguageAccess($fieldValue)
1298
            ) {
1299
                continue;
1300
            }
1301
1302
            switch ($field) {
1303
                case 'uid':
1304
                case 'pid':
1305
                    // Nothing happens, already set
1306
                    break;
1307
                case 'perms_userid':
1308
                case 'perms_groupid':
1309
                case 'perms_user':
1310
                case 'perms_group':
1311
                case 'perms_everybody':
1312
                    // Permissions can be edited by the owner or the administrator
1313
                    if ($table === 'pages' && ($this->admin || $status === 'new' || $this->pageInfo((int)$id, 'perms_userid') == $this->userid)) {
1314
                        $value = (int)$fieldValue;
1315
                        switch ($field) {
1316
                            case 'perms_userid':
1317
                            case 'perms_groupid':
1318
                                $fieldArray[$field] = $value;
1319
                                break;
1320
                            default:
1321
                                if ($value >= 0 && $value < (2 ** 5)) {
1322
                                    $fieldArray[$field] = $value;
1323
                                }
1324
                        }
1325
                    }
1326
                    break;
1327
                case 't3ver_oid':
1328
                case 't3ver_wsid':
1329
                case 't3ver_state':
1330
                case 't3ver_stage':
1331
                    break;
1332
                case 'l10n_state':
1333
                    $fieldArray[$field] = $fieldValue;
1334
                    break;
1335
                default:
1336
                    if (isset($GLOBALS['TCA'][$table]['columns'][$field])) {
1337
                        // Evaluating the value
1338
                        $res = $this->checkValue($table, $field, $fieldValue, $id, $status, $realPid, $tscPID, $incomingFieldArray);
1339
                        if (array_key_exists('value', $res)) {
1340
                            $fieldArray[$field] = $res['value'];
1341
                        }
1342
                        // Add the value of the original record to the diff-storage content:
1343
                        if ($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] ?? false) {
1344
                            $originalLanguage_diffStorage[$field] = (string)($originalLanguageRecord[$field] ?? '');
1345
                            $diffStorageFlag = true;
1346
                        }
1347
                    } elseif (isset($GLOBALS['TCA'][$table]['ctrl']['origUid']) && $GLOBALS['TCA'][$table]['ctrl']['origUid'] === $field) {
1348
                        // Allow value for original UID to pass by...
1349
                        $fieldArray[$field] = $fieldValue;
1350
                    }
1351
            }
1352
        }
1353
1354
        // Dealing with a page translation, setting "sorting", "pid", "perms_*" to the same values as the original record
1355
        if ($table === 'pages' && is_array($originalLanguageRecord)) {
1356
            $fieldArray['sorting'] = $originalLanguageRecord['sorting'];
1357
            $fieldArray['perms_userid'] = $originalLanguageRecord['perms_userid'];
1358
            $fieldArray['perms_groupid'] = $originalLanguageRecord['perms_groupid'];
1359
            $fieldArray['perms_user'] = $originalLanguageRecord['perms_user'];
1360
            $fieldArray['perms_group'] = $originalLanguageRecord['perms_group'];
1361
            $fieldArray['perms_everybody'] = $originalLanguageRecord['perms_everybody'];
1362
        }
1363
1364
        // Add diff-storage information:
1365
        if ($diffStorageFlag
1366
            && !array_key_exists($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'], $fieldArray)
1367
        ) {
1368
            // If the field is set it would probably be because of an undo-operation - in which case we should not update the field of course...
1369
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = json_encode($originalLanguage_diffStorage);
1370
        }
1371
        // Return fieldArray
1372
        return $fieldArray;
1373
    }
1374
1375
    /*********************************************
1376
     *
1377
     * Evaluation of input values
1378
     *
1379
     ********************************************/
1380
    /**
1381
     * Evaluates a value according to $table/$field settings.
1382
     * This function is for real database fields - NOT FlexForm "pseudo" fields.
1383
     * NOTICE: Calling this function expects this: 1) That the data is saved!
1384
     *
1385
     * @param string $table Table name
1386
     * @param string $field Field name
1387
     * @param string $value Value to be evaluated. Notice, this is the INPUT value from the form. The original value (from any existing record) must be manually looked up inside the function if needed - or taken from $currentRecord array.
1388
     * @param int|string $id The record-uid, mainly - but not exclusively - used for logging
1389
     * @param string $status 'update' or 'new' flag
1390
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1391
     * @param int $tscPID TSconfig PID
1392
     * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray)
1393
     * @return array Returns the evaluated $value as key "value" in this array. Can be checked with isset($res['value']) ...
1394
     * @internal should only be used from within DataHandler
1395
     */
1396
    public function checkValue($table, $field, $value, $id, $status, $realPid, $tscPID, $incomingFieldArray = [])
1397
    {
1398
        $curValueRec = null;
1399
        // Result array
1400
        $res = [];
1401
1402
        // Processing special case of field pages.doktype
1403
        if ($table === 'pages' && $field === 'doktype') {
1404
            // If the user may not use this specific doktype, we issue a warning
1405
            if (!($this->admin || GeneralUtility::inList($this->BE_USER->groupData['pagetypes_select'], $value))) {
1406
                if ($this->enableLogging) {
1407
                    $propArr = $this->getRecordProperties($table, $id);
0 ignored issues
show
Bug introduced by
It seems like $id can also be of type string; however, parameter $id of TYPO3\CMS\Core\DataHandl...::getRecordProperties() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1407
                    $propArr = $this->getRecordProperties($table, /** @scrutinizer ignore-type */ $id);
Loading history...
1408
                    $this->log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'You cannot change the \'doktype\' of page \'%s\' to the desired value.', 1, [$propArr['header']], $propArr['event_pid']);
1409
                }
1410
                return $res;
1411
            }
1412
            if ($status === 'update') {
1413
                // This checks 1) if we should check for disallowed tables and 2) if there are records from disallowed tables on the current page
1414
                $onlyAllowedTables = $GLOBALS['PAGES_TYPES'][$value]['onlyAllowedTables'] ?? $GLOBALS['PAGES_TYPES']['default']['onlyAllowedTables'];
1415
                if ($onlyAllowedTables) {
1416
                    // use the real page id (default language)
1417
                    $recordId = $this->getDefaultLanguagePageId((int)$id);
1418
                    $theWrongTables = $this->doesPageHaveUnallowedTables($recordId, (int)$value);
1419
                    if ($theWrongTables) {
1420
                        if ($this->enableLogging) {
1421
                            $propArr = $this->getRecordProperties($table, $id);
1422
                            $this->log($table, (int)$id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, '\'doktype\' of page \'%s\' could not be changed because the page contains records from disallowed tables; %s', 2, [$propArr['header'], $theWrongTables], $propArr['event_pid']);
1423
                        }
1424
                        return $res;
1425
                    }
1426
                }
1427
            }
1428
        }
1429
1430
        $curValue = null;
1431
        if ((int)$id !== 0) {
1432
            // Get current value:
1433
            $curValueRec = $this->recordInfo($table, (int)$id, $field);
1434
            // isset() won't work here, since values can be NULL
1435
            if ($curValueRec !== null && array_key_exists($field, $curValueRec)) {
1436
                $curValue = $curValueRec[$field];
1437
            }
1438
        }
1439
1440
        if ($table === 'be_users'
1441
            && ($field === 'admin' || $field === 'password')
1442
            && $status === 'update'
1443
        ) {
1444
            // Do not allow a non system maintainer admin to change admin flag and password of system maintainers
1445
            $systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
1446
            // False if current user is not in system maintainer list or if switch to user mode is active
1447
            $isCurrentUserSystemMaintainer = $this->BE_USER->isSystemMaintainer();
1448
            $isTargetUserInSystemMaintainerList = in_array((int)$id, $systemMaintainers, true);
1449
            if ($field === 'admin') {
1450
                $isFieldChanged = (int)$curValueRec[$field] !== (int)$value;
1451
            } else {
1452
                $isFieldChanged = $curValueRec[$field] !== $value;
1453
            }
1454
            if (!$isCurrentUserSystemMaintainer && $isTargetUserInSystemMaintainerList && $isFieldChanged) {
1455
                $value = $curValueRec[$field];
1456
                $this->log(
1457
                    $table,
1458
                    (int)$id,
1459
                    SystemLogDatabaseAction::UPDATE,
1460
                    0,
1461
                    SystemLogErrorClassification::SECURITY_NOTICE,
1462
                    'Only system maintainers can change the admin flag and password of other system maintainers. The value has not been updated.',
1463
                    -1,
1464
                    [$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.adminCanNotChangeSystemMaintainer')]
1465
                );
1466
            }
1467
        }
1468
1469
        // Getting config for the field
1470
        $tcaFieldConf = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field);
1471
1472
        // Create $recFID only for those types that need it
1473
        if ($tcaFieldConf['type'] === 'flex') {
1474
            $recFID = $table . ':' . $id . ':' . $field;
1475
        } else {
1476
            $recFID = '';
1477
        }
1478
1479
        // Perform processing:
1480
        $res = $this->checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, [], $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
0 ignored issues
show
Bug introduced by
It seems like $id can also be of type string; however, parameter $id of TYPO3\CMS\Core\DataHandl...andler::checkValue_SW() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1480
        $res = $this->checkValue_SW($res, $value, $tcaFieldConf, $table, /** @scrutinizer ignore-type */ $id, $curValue, $status, $realPid, $recFID, $field, [], $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
Loading history...
1481
        return $res;
1482
    }
1483
1484
    /**
1485
     * Use columns overrides for evaluation.
1486
     *
1487
     * Fetch the TCA ["config"] part for a specific field, including the columnsOverrides value.
1488
     * Used for checkValue purposes currently (as it takes the checkValue_currentRecord value).
1489
     *
1490
     * @param string $table
1491
     * @param string $field
1492
     * @return array
1493
     */
1494
    protected function resolveFieldConfigurationAndRespectColumnsOverrides(string $table, string $field): array
1495
    {
1496
        $tcaFieldConf = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
1497
        $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1498
        $columnsOverridesConfigOfField = $GLOBALS['TCA'][$table]['types'][$recordType]['columnsOverrides'][$field]['config'] ?? null;
1499
        if ($columnsOverridesConfigOfField) {
1500
            ArrayUtility::mergeRecursiveWithOverrule($tcaFieldConf, $columnsOverridesConfigOfField);
1501
        }
1502
        return $tcaFieldConf;
1503
    }
1504
1505
    /**
1506
     * Branches out evaluation of a field value based on its type as configured in $GLOBALS['TCA']
1507
     * Can be called for FlexForm pseudo fields as well, BUT must not have $field set if so.
1508
     * And hey, there's a good thing about the method arguments: 13 is prime :-P
1509
     *
1510
     * @param array $res The result array. The processed value (if any!) is set in the "value" key.
1511
     * @param string $value The value to set.
1512
     * @param array $tcaFieldConf Field configuration from $GLOBALS['TCA']
1513
     * @param string $table Table name
1514
     * @param int $id UID of record
1515
     * @param mixed $curValue Current value of the field
1516
     * @param string $status 'update' or 'new' flag
1517
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1518
     * @param string $recFID Field identifier [table:uid:field] for flexforms
1519
     * @param string $field Field name. Must NOT be set if the call is for a flexform field (since flexforms are not allowed within flexforms).
1520
     * @param array $uploadedFiles
1521
     * @param int $tscPID TSconfig PID
1522
     * @param array|null $additionalData Additional data to be forwarded to sub-processors
1523
     * @return array Returns the evaluated $value as key "value" in this array.
1524
     * @internal should only be used from within DataHandler
1525
     */
1526
    public function checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $uploadedFiles, $tscPID, array $additionalData = null)
1527
    {
1528
        // Convert to NULL value if defined in TCA
1529
        if ($value === null && !empty($tcaFieldConf['eval']) && GeneralUtility::inList($tcaFieldConf['eval'], 'null')) {
0 ignored issues
show
introduced by
The condition $value === null is always false.
Loading history...
1530
            $res = ['value' => null];
1531
            return $res;
1532
        }
1533
1534
        switch ($tcaFieldConf['type']) {
1535
            case 'text':
1536
                $res = $this->checkValueForText($value, $tcaFieldConf, $table, $realPid, $field);
1537
                break;
1538
            case 'passthrough':
1539
            case 'imageManipulation':
1540
            case 'user':
1541
                $res['value'] = $value;
1542
                break;
1543
            case 'input':
1544
                $res = $this->checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field);
1545
                break;
1546
            case 'slug':
1547
                $res = $this->checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []);
1548
                break;
1549
            case 'language':
1550
                $res = $this->checkValueForLanguage((int)$value, $table, $field);
1551
                break;
1552
            case 'check':
1553
                $res = $this->checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
1554
                break;
1555
            case 'radio':
1556
                $res = $this->checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
1557
                break;
1558
            case 'group':
1559
            case 'select':
1560
                $res = $this->checkValueForGroupSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field);
1561
                break;
1562
            case 'inline':
1563
                $res = $this->checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData) ?: [];
1564
                break;
1565
            case 'flex':
1566
                // FlexForms are only allowed for real fields.
1567
                if ($field) {
1568
                    $res = $this->checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $uploadedFiles, $field);
1569
                }
1570
                break;
1571
            default:
1572
                // Do nothing
1573
        }
1574
        $res = $this->checkValueForInternalReferences($res, $value, $tcaFieldConf, $table, $id, $field);
1575
        return $res;
1576
    }
1577
1578
    /**
1579
     * Checks values that are used for internal references. If the provided $value
1580
     * is a NEW-identifier, the direct processing is stopped. Instead, the value is
1581
     * forwarded to the remap-stack to be post-processed and resolved into a proper
1582
     * UID after all data has been resolved.
1583
     *
1584
     * This method considers TCA types that cannot handle and resolve these internal
1585
     * values directly, like 'passthrough', 'none' or 'user'. Values are only modified
1586
     * here if the $field is used as 'transOrigPointerField' or 'translationSource'.
1587
     *
1588
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1589
     * @param string $value The value to set.
1590
     * @param array $tcaFieldConf Field configuration from TCA
1591
     * @param string $table Table name
1592
     * @param int|string $id UID of record
1593
     * @param string $field The field name
1594
     * @return array The result array. The processed value (if any!) is set in the "value" key.
1595
     */
1596
    protected function checkValueForInternalReferences(array $res, $value, $tcaFieldConf, $table, $id, $field)
1597
    {
1598
        $relevantFieldNames = [
1599
            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null,
1600
            $GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null,
1601
        ];
1602
1603
        if (
1604
            // in case field is empty
1605
            empty($field)
1606
            // in case the field is not relevant
1607
            || !in_array($field, $relevantFieldNames)
1608
            // in case the 'value' index has been unset already
1609
            || !array_key_exists('value', $res)
1610
            // in case it's not a NEW-identifier
1611
            || strpos($value, 'NEW') === false
1612
        ) {
1613
            return $res;
1614
        }
1615
1616
        $valueArray = [$value];
1617
        $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1618
        $this->addNewValuesToRemapStackChildIds($valueArray);
1619
        $this->remapStack[] = [
1620
            'args' => [$valueArray, $tcaFieldConf, $id, $table, $field],
1621
            'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
1622
            'field' => $field
1623
        ];
1624
        unset($res['value']);
1625
1626
        return $res;
1627
    }
1628
1629
    /**
1630
     * Evaluate "text" type values.
1631
     *
1632
     * @param string $value The value to set.
1633
     * @param array $tcaFieldConf Field configuration from TCA
1634
     * @param string $table Table name
1635
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1636
     * @param string $field Field name
1637
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1638
     */
1639
    protected function checkValueForText($value, $tcaFieldConf, $table, $realPid, $field)
1640
    {
1641
        if (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') {
1642
            $cacheId = $this->getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1643
            $evalCodesArray = $this->runtimeCache->get($cacheId);
1644
            if (!is_array($evalCodesArray)) {
1645
                $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1646
                $this->runtimeCache->set($cacheId, $evalCodesArray);
1647
            }
1648
            $valueArray = $this->checkValue_text_Eval($value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '');
1649
        } else {
1650
            $valueArray = ['value' => $value];
1651
        }
1652
1653
        // Handle richtext transformations
1654
        if ($this->dontProcessTransformations) {
1655
            return $valueArray;
1656
        }
1657
        // Keep null as value
1658
        if ($value === null) {
0 ignored issues
show
introduced by
The condition $value === null is always false.
Loading history...
1659
            return $valueArray;
1660
        }
1661
        if (isset($tcaFieldConf['enableRichtext']) && (bool)$tcaFieldConf['enableRichtext'] === true) {
1662
            $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1663
            $richtextConfigurationProvider = GeneralUtility::makeInstance(Richtext::class);
1664
            $richtextConfiguration = $richtextConfigurationProvider->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf);
1665
            $rteParser = GeneralUtility::makeInstance(RteHtmlParser::class);
1666
            $valueArray['value'] = $rteParser->transformTextForPersistence((string)$value, $richtextConfiguration['proc.'] ?? []);
1667
        }
1668
1669
        return $valueArray;
1670
    }
1671
1672
    /**
1673
     * Evaluate "input" type values.
1674
     *
1675
     * @param string $value The value to set.
1676
     * @param array $tcaFieldConf Field configuration from TCA
1677
     * @param string $table Table name
1678
     * @param int $id UID of record
1679
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1680
     * @param string $field Field name
1681
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1682
     */
1683
    protected function checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field)
1684
    {
1685
        // Handle native date/time fields
1686
        $isDateOrDateTimeField = false;
1687
        $format = '';
1688
        $emptyValue = '';
1689
        $dateTimeTypes = QueryHelper::getDateTimeTypes();
1690
        // normal integer "date" fields (timestamps) are handled in checkValue_input_Eval
1691
        if (isset($tcaFieldConf['dbType']) && in_array($tcaFieldConf['dbType'], $dateTimeTypes, true)) {
1692
            if (empty($value)) {
1693
                $value = null;
1694
            } else {
1695
                $isDateOrDateTimeField = true;
1696
                $dateTimeFormats = QueryHelper::getDateTimeFormats();
1697
                $format = $dateTimeFormats[$tcaFieldConf['dbType']]['format'];
1698
1699
                // Convert the date/time into a timestamp for the sake of the checks
1700
                $emptyValue = $dateTimeFormats[$tcaFieldConf['dbType']]['empty'];
1701
                // We expect the ISO 8601 $value to contain a UTC timezone specifier.
1702
                // We explicitly fallback to UTC if no timezone specifier is given (e.g. for copy operations).
1703
                $dateTime = new \DateTime($value, new \DateTimeZone('UTC'));
1704
                // The timestamp (UTC) returned by getTimestamp() will be converted to
1705
                // a local time string by gmdate() later.
1706
                $value = $value === $emptyValue ? null : $dateTime->getTimestamp();
1707
            }
1708
        }
1709
        // Secures the string-length to be less than max.
1710
        if (isset($tcaFieldConf['max']) && (int)$tcaFieldConf['max'] > 0) {
1711
            $value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8');
1712
        }
1713
1714
        if (empty($tcaFieldConf['eval'])) {
1715
            $res = ['value' => $value];
1716
        } else {
1717
            // Process evaluation settings:
1718
            $cacheId = $this->getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1719
            $evalCodesArray = $this->runtimeCache->get($cacheId);
1720
            if (!is_array($evalCodesArray)) {
1721
                $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1722
                $this->runtimeCache->set($cacheId, $evalCodesArray);
1723
            }
1724
1725
            $res = $this->checkValue_input_Eval((string)$value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '', $table, $id);
1726
            if (isset($tcaFieldConf['dbType']) && isset($res['value']) && !$res['value']) {
1727
                // set the value to null if we have an empty value for a native field
1728
                $res['value'] = null;
1729
            }
1730
1731
            // Process UNIQUE settings:
1732
            // Field is NOT set for flexForms - which also means that uniqueInPid and unique is NOT available for flexForm fields! Also getUnique should not be done for versioning
1733
            if ($field && !empty($res['value'])) {
1734
                if (in_array('uniqueInPid', $evalCodesArray, true)) {
1735
                    $res['value'] = $this->getUnique($table, $field, $res['value'], $id, $realPid);
1736
                }
1737
                if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1738
                    $res['value'] = $this->getUnique($table, $field, $res['value'], $id);
1739
                }
1740
            }
1741
        }
1742
1743
        // Checking range of value:
1744
        // @todo: The "checkbox" option was removed for type=input, this check could be probably relaxed?
1745
        if (
1746
            isset($tcaFieldConf['range']) && $tcaFieldConf['range']
1747
            && (!isset($tcaFieldConf['checkbox']) || $res['value'] != $tcaFieldConf['checkbox'])
1748
            && (!isset($tcaFieldConf['default']) || (int)$res['value'] !== (int)$tcaFieldConf['default'])
1749
        ) {
1750
            if (isset($tcaFieldConf['range']['upper']) && (int)$res['value'] > (int)$tcaFieldConf['range']['upper']) {
1751
                $res['value'] = (int)$tcaFieldConf['range']['upper'];
1752
            }
1753
            if (isset($tcaFieldConf['range']['lower']) && (int)$res['value'] < (int)$tcaFieldConf['range']['lower']) {
1754
                $res['value'] = (int)$tcaFieldConf['range']['lower'];
1755
            }
1756
        }
1757
1758
        // Handle native date/time fields
1759
        if ($isDateOrDateTimeField) {
1760
            // Convert the timestamp back to a date/time
1761
            $res['value'] = $res['value'] ? gmdate($format, $res['value']) : $emptyValue;
1762
        }
1763
        return $res;
1764
    }
1765
1766
    /**
1767
     * Evaluate "slug" type values.
1768
     *
1769
     * @param string $value The value to set.
1770
     * @param array $tcaFieldConf Field configuration from TCA
1771
     * @param string $table Table name
1772
     * @param int $id UID of record
1773
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1774
     * @param string $field Field name
1775
     * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray) for the record
1776
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1777
     * @see SlugEnricher
1778
     * @see SlugHelper
1779
     */
1780
    protected function checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array
1781
    {
1782
        $workspaceId = $this->BE_USER->workspace;
1783
        $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId);
1784
        $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray ?? []);
1785
        // Generate a value if there is none, otherwise ensure that all characters are cleaned up
1786
        if ($value === '') {
1787
            $value = $helper->generate($fullRecord, $realPid);
1788
        } else {
1789
            $value = $helper->sanitize($value);
1790
        }
1791
1792
        // Return directly in case no evaluations are defined
1793
        if (empty($tcaFieldConf['eval'])) {
1794
            return ['value' => $value];
1795
        }
1796
1797
        $state = RecordStateFactory::forName($table)
1798
            ->fromArray($fullRecord, $realPid, $id);
1799
        $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1800
        if (in_array('unique', $evalCodesArray, true)) {
1801
            $value = $helper->buildSlugForUniqueInTable($value, $state);
1802
        }
1803
        if (in_array('uniqueInSite', $evalCodesArray, true)) {
1804
            $value = $helper->buildSlugForUniqueInSite($value, $state);
1805
        }
1806
        if (in_array('uniqueInPid', $evalCodesArray, true)) {
1807
            $value = $helper->buildSlugForUniqueInPid($value, $state);
1808
        }
1809
1810
        return ['value' => $value];
1811
    }
1812
1813
    /**
1814
     * Evaluate "language" type value.
1815
     *
1816
     * Checks whether the user is allowed to add such a value as language
1817
     *
1818
     * @param int $value The value to set.
1819
     * @param string $table Table name
1820
     * @param string $field Field name
1821
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1822
     */
1823
    protected function checkValueForLanguage(int $value, string $table, string $field): array
1824
    {
1825
        // If given table is localizable and the given field is the defined
1826
        // languageField, check if the selected language is allowed for the user.
1827
        // Note: Usually this method should never be reached, in case the language value is
1828
        // not valid, since recordEditAccessInternals checks for proper permission beforehand.
1829
        if (BackendUtility::isTableLocalizable($table)
1830
            && ($GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') === $field
1831
            && !$this->BE_USER->checkLanguageAccess($value)
1832
        ) {
1833
            return [];
1834
        }
1835
1836
        // @todo Should we also check if the language is allowed for the current site - if record has site context?
1837
1838
        return ['value' => $value];
1839
    }
1840
1841
    /**
1842
     * Evaluates 'check' type values.
1843
     *
1844
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1845
     * @param string $value The value to set.
1846
     * @param array $tcaFieldConf Field configuration from TCA
1847
     * @param string $table Table name
1848
     * @param int $id UID of record
1849
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
1850
     * @param string $field Field name
1851
     * @return array Modified $res array
1852
     */
1853
    protected function checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field)
1854
    {
1855
        $items = $tcaFieldConf['items'] ?? null;
1856
        if (!empty($tcaFieldConf['itemsProcFunc'])) {
1857
            /** @var ItemProcessingService $processingService */
1858
            $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
1859
            $items = $processingService->getProcessingItems(
1860
                $table,
1861
                $realPid,
1862
                $field,
1863
                $this->checkValue_currentRecord,
1864
                $tcaFieldConf,
1865
                $tcaFieldConf['items']
1866
            );
1867
        }
1868
1869
        $itemC = 0;
1870
        if ($items !== null) {
1871
            $itemC = count($items);
1872
        }
1873
        if (!$itemC) {
1874
            $itemC = 1;
1875
        }
1876
        $maxV = (2 ** $itemC) - 1;
1877
        if ($value < 0) {
1878
            // @todo: throw LogicException here? Negative values for checkbox items do not make sense and indicate a coding error.
1879
            $value = 0;
1880
        }
1881
        if ($value > $maxV) {
1882
            // @todo: This case is pretty ugly: If there is an itemsProcFunc registered, and if it returns a dynamic,
1883
            // @todo: changing list of items, then it may happen that a value is transformed and vanished checkboxes
1884
            // @todo: are permanently removed from the value.
1885
            // @todo: Suggestion: Throw an exception instead? Maybe a specific, catchable exception that generates a
1886
            // @todo: error message to the user - dynamic item sets via itemProcFunc on check would be a bad idea anyway.
1887
            $value = $value & $maxV;
1888
        }
1889
        if ($field && $value > 0 && !empty($tcaFieldConf['eval'])) {
1890
            $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1891
            $otherRecordsWithSameValue = [];
1892
            $maxCheckedRecords = 0;
1893
            if (in_array('maximumRecordsCheckedInPid', $evalCodesArray, true)) {
1894
                $otherRecordsWithSameValue = $this->getRecordsWithSameValue($table, $id, $field, $value, $realPid);
1895
                $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsCheckedInPid'];
1896
            }
1897
            if (in_array('maximumRecordsChecked', $evalCodesArray, true)) {
1898
                $otherRecordsWithSameValue = $this->getRecordsWithSameValue($table, $id, $field, $value);
1899
                $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsChecked'];
1900
            }
1901
1902
            // there are more than enough records with value "1" in the DB
1903
            // if so, set this value to "0" again
1904
            if ($maxCheckedRecords && count($otherRecordsWithSameValue) >= $maxCheckedRecords) {
1905
                $value = 0;
1906
                $this->log($table, $id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Could not activate checkbox for field "%s". A total of %s record(s) can have this checkbox activated. Uncheck other records first in order to activate the checkbox of this record.', -1, [$this->getLanguageService()->sL(BackendUtility::getItemLabel($table, $field)), $maxCheckedRecords]);
1907
            }
1908
        }
1909
        $res['value'] = $value;
1910
        return $res;
1911
    }
1912
1913
    /**
1914
     * Evaluates 'radio' type values.
1915
     *
1916
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1917
     * @param string $value The value to set.
1918
     * @param array $tcaFieldConf Field configuration from TCA
1919
     * @param string $table The table of the record
1920
     * @param int $id The id of the record
1921
     * @param int $pid The pid of the record
1922
     * @param string $field The field to check
1923
     * @return array Modified $res array
1924
     */
1925
    protected function checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $pid, $field)
1926
    {
1927
        if (is_array($tcaFieldConf['items'])) {
1928
            foreach ($tcaFieldConf['items'] as $set) {
1929
                if ((string)$set[1] === (string)$value) {
1930
                    $res['value'] = $value;
1931
                    break;
1932
                }
1933
            }
1934
        }
1935
1936
        // if no value was found and an itemsProcFunc is defined, check that for the value
1937
        if (!empty($tcaFieldConf['itemsProcFunc']) && empty($res['value'])) {
1938
            $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
1939
            $processedItems = $processingService->getProcessingItems(
1940
                $table,
1941
                $pid,
1942
                $field,
1943
                $this->checkValue_currentRecord,
1944
                $tcaFieldConf,
1945
                $tcaFieldConf['items']
1946
            );
1947
1948
            foreach ($processedItems as $set) {
1949
                if ((string)$set[1] === (string)$value) {
1950
                    $res['value'] = $value;
1951
                    break;
1952
                }
1953
            }
1954
        }
1955
1956
        return $res;
1957
    }
1958
1959
    /**
1960
     * Evaluates 'group' or 'select' type values.
1961
     *
1962
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1963
     * @param string|array $value The value to set.
1964
     * @param array $tcaFieldConf Field configuration from TCA
1965
     * @param string $table Table name
1966
     * @param int $id UID of record
1967
     * @param string $status 'update' or 'new' flag
1968
     * @param string $field Field name
1969
     * @return array Modified $res array
1970
     */
1971
    protected function checkValueForGroupSelect($res, $value, $tcaFieldConf, $table, $id, $status, $field)
1972
    {
1973
        // Detecting if value sent is an array and if so, implode it around a comma:
1974
        if (is_array($value)) {
1975
            $value = implode(',', $value);
1976
        }
1977
        // This converts all occurrences of '&#123;' to the byte 123 in the string - this is needed in very rare cases where file names with special characters (e.g. ???, umlaut) gets sent to the server as HTML entities instead of bytes. The error is done only by MSIE, not Mozilla and Opera.
1978
        // Anyway, this should NOT disturb anything else:
1979
        $value = $this->convNumEntityToByteValue($value);
1980
        // When values are sent as group or select they come as comma-separated values which are exploded by this function:
1981
        $valueArray = $this->checkValue_group_select_explodeSelectGroupValue($value);
1982
        // If multiple is not set, remove duplicates:
1983
        if (!($tcaFieldConf['multiple'] ?? false)) {
1984
            $valueArray = array_unique($valueArray);
1985
        }
1986
        // If an exclusive key is found, discard all others:
1987
        if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['exclusiveKeys'] ?? false)) {
1988
            $exclusiveKeys = GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
1989
            foreach ($valueArray as $index => $key) {
1990
                if (in_array($key, $exclusiveKeys, true)) {
1991
                    $valueArray = [$index => $key];
1992
                    break;
1993
                }
1994
            }
1995
        }
1996
        // This could be a good spot for parsing the array through a validation-function which checks if the values are correct (except that database references are not in their final form - but that is the point, isn't it?)
1997
        // NOTE!!! Must check max-items of files before the later check because that check would just leave out file names if there are too many!!
1998
        $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray);
1999
        // Checking for select / authMode, removing elements from $valueArray if any of them is not allowed!
2000
        if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['authMode'] ?? false)) {
2001
            $preCount = count($valueArray);
2002
            foreach ($valueArray as $index => $key) {
2003
                if (!$this->BE_USER->checkAuthMode($table, $field, $key, $tcaFieldConf['authMode'])) {
2004
                    unset($valueArray[$index]);
2005
                }
2006
            }
2007
            // During the check it turns out that the value / all values were removed - we respond by simply returning an empty array so nothing is written to DB for this field.
2008
            if ($preCount && empty($valueArray)) {
2009
                return [];
2010
            }
2011
        }
2012
        // For select types which has a foreign table attached:
2013
        $unsetResult = false;
2014
        if (($tcaFieldConf['type'] === 'group' && $tcaFieldConf['internal_type'] === 'db')
2015
            || ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['foreign_table'] ?? false))
2016
        ) {
2017
            // check, if there is a NEW... id in the value, that should be substituted later
2018
            if (strpos($value, 'NEW') !== false) {
2019
                $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2020
                $this->addNewValuesToRemapStackChildIds($valueArray);
2021
                $this->remapStack[] = [
2022
                    'func' => 'checkValue_group_select_processDBdata',
2023
                    'args' => [$valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field],
2024
                    'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 5],
2025
                    'field' => $field
2026
                ];
2027
                $unsetResult = true;
2028
            } else {
2029
                $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field);
2030
            }
2031
        }
2032
        if (!$unsetResult) {
2033
            $newVal = $this->checkValue_checkMax($tcaFieldConf, $valueArray);
2034
            $res['value'] = $this->castReferenceValue(implode(',', $newVal), $tcaFieldConf);
2035
        } else {
2036
            unset($res['value']);
2037
        }
2038
        return $res;
2039
    }
2040
2041
    /**
2042
     * Applies the filter methods from a column's TCA configuration to a value array.
2043
     *
2044
     * @param array $tcaFieldConfiguration
2045
     * @param array $values
2046
     * @return array|mixed
2047
     * @throws \RuntimeException
2048
     */
2049
    protected function applyFiltersToValues(array $tcaFieldConfiguration, array $values)
2050
    {
2051
        if (empty($tcaFieldConfiguration['filter']) || !is_array($tcaFieldConfiguration['filter'])) {
2052
            return $values;
2053
        }
2054
        foreach ($tcaFieldConfiguration['filter'] as $filter) {
2055
            if (empty($filter['userFunc'])) {
2056
                continue;
2057
            }
2058
            $parameters = $filter['parameters'] ?: [];
2059
            $parameters['values'] = $values;
2060
            $parameters['tcaFieldConfig'] = $tcaFieldConfiguration;
2061
            $values = GeneralUtility::callUserFunction($filter['userFunc'], $parameters, $this);
2062
            if (!is_array($values)) {
2063
                throw new \RuntimeException('Failed calling filter userFunc.', 1336051942);
2064
            }
2065
        }
2066
        return $values;
2067
    }
2068
2069
    /**
2070
     * Evaluates 'flex' type values.
2071
     *
2072
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
2073
     * @param string|array $value The value to set.
2074
     * @param array $tcaFieldConf Field configuration from TCA
2075
     * @param string $table Table name
2076
     * @param int $id UID of record
2077
     * @param mixed $curValue Current value of the field
2078
     * @param string $status 'update' or 'new' flag
2079
     * @param int $realPid The real PID value of the record. For updates, this is just the pid of the record. For new records this is the PID of the page where it is inserted.
2080
     * @param string $recFID Field identifier [table:uid:field] for flexforms
2081
     * @param int $tscPID TSconfig PID
2082
     * @param array $uploadedFiles Uploaded files for the field
2083
     * @param string $field Field name
2084
     * @return array Modified $res array
2085
     */
2086
    protected function checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $uploadedFiles, $field)
2087
    {
2088
        if (is_array($value)) {
2089
            // This value is necessary for flex form processing to happen on flexform fields in page records when they are copied.
2090
            // Problem: when copying a page, flexform XML comes along in the array for the new record - but since $this->checkValue_currentRecord
2091
            // does not have a uid or pid for that sake, the FlexFormTools->getDataStructureIdentifier() function returns no good DS. For new
2092
            // records we do know the expected PID so therefore we send that with this special parameter. Only active when larger than zero.
2093
            $row = $this->checkValue_currentRecord;
2094
            if ($status === 'new') {
2095
                $row['pid'] = $realPid;
2096
            }
2097
2098
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
2099
2100
            // Get data structure. The methods may throw various exceptions, with some of them being
2101
            // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
2102
            // and substitute with a dummy DS.
2103
            $dataStructureArray = ['sheets' => ['sDEF' => []]];
2104
            try {
2105
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
2106
                    ['config' => $tcaFieldConf],
2107
                    $table,
2108
                    $field,
2109
                    $row
2110
                );
2111
2112
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
2113
            } catch (InvalidParentRowException|InvalidParentRowLoopException|InvalidParentRowRootException|InvalidPointerFieldValueException|InvalidIdentifierException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
2114
            }
2115
2116
            // Get current value array:
2117
            $currentValueArray = (string)$curValue !== '' ? GeneralUtility::xml2array($curValue) : [];
2118
            if (!is_array($currentValueArray)) {
2119
                $currentValueArray = [];
2120
            }
2121
            // Remove all old meta for languages...
2122
            // Evaluation of input values:
2123
            $value['data'] = $this->checkValue_flex_procInData($value['data'] ?? [], $currentValueArray['data'] ?? [], $uploadedFiles['data'] ?? [], $dataStructureArray, [$table, $id, $curValue, $status, $realPid, $recFID, $tscPID]);
2124
            // Create XML from input value:
2125
            $xmlValue = $this->checkValue_flexArray2Xml($value, true);
2126
2127
            // Here we convert the currently submitted values BACK to an array, then merge the two and then BACK to XML again. This is needed to ensure the charsets are the same
2128
            // (provided that the current value was already stored IN the charset that the new value is converted to).
2129
            $arrValue = GeneralUtility::xml2array($xmlValue);
2130
2131
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkFlexFormValue'] ?? [] as $className) {
2132
                $hookObject = GeneralUtility::makeInstance($className);
2133
                if (method_exists($hookObject, 'checkFlexFormValue_beforeMerge')) {
2134
                    $hookObject->checkFlexFormValue_beforeMerge($this, $currentValueArray, $arrValue);
2135
                }
2136
            }
2137
2138
            ArrayUtility::mergeRecursiveWithOverrule($currentValueArray, $arrValue);
2139
            $xmlValue = $this->checkValue_flexArray2Xml($currentValueArray, true);
2140
2141
            // Action commands (sorting order and removals of elements) for flexform sections,
2142
            // see FormEngine for the use of this GP parameter
2143
            $actionCMDs = GeneralUtility::_GP('_ACTION_FLEX_FORMdata');
2144
            if (is_array($actionCMDs[$table][$id][$field]['data'] ?? null)) {
2145
                $arrValue = GeneralUtility::xml2array($xmlValue);
2146
                $this->_ACTION_FLEX_FORMdata($arrValue['data'], $actionCMDs[$table][$id][$field]['data']);
2147
                $xmlValue = $this->checkValue_flexArray2Xml($arrValue, true);
2148
            }
2149
            // Create the value XML:
2150
            $res['value'] = '';
2151
            $res['value'] .= $xmlValue;
2152
        } else {
2153
            // Passthrough...:
2154
            $res['value'] = $value;
2155
        }
2156
2157
        return $res;
2158
    }
2159
2160
    /**
2161
     * Converts an array to FlexForm XML
2162
     *
2163
     * @param array $array Array with FlexForm data
2164
     * @param bool $addPrologue If set, the XML prologue is returned as well.
2165
     * @return string Input array converted to XML
2166
     * @internal should only be used from within DataHandler
2167
     */
2168
    public function checkValue_flexArray2Xml($array, $addPrologue = false)
2169
    {
2170
        /** @var FlexFormTools $flexObj */
2171
        $flexObj = GeneralUtility::makeInstance(FlexFormTools::class);
2172
        return $flexObj->flexArray2Xml($array, $addPrologue);
2173
    }
2174
2175
    /**
2176
     * Actions for flex form element (move, delete)
2177
     * allows to remove and move flexform sections
2178
     *
2179
     * @param array $valueArray by reference
2180
     * @param array $actionCMDs
2181
     */
2182
    protected function _ACTION_FLEX_FORMdata(&$valueArray, $actionCMDs)
2183
    {
2184
        if (!is_array($valueArray) || !is_array($actionCMDs)) {
0 ignored issues
show
introduced by
The condition is_array($valueArray) is always true.
Loading history...
introduced by
The condition is_array($actionCMDs) is always true.
Loading history...
2185
            return;
2186
        }
2187
2188
        foreach ($actionCMDs as $key => $value) {
2189
            if ($key === '_ACTION') {
2190
                // First, check if there are "commands":
2191
                if (empty(array_filter($actionCMDs[$key]))) {
2192
                    continue;
2193
                }
2194
2195
                asort($actionCMDs[$key]);
2196
                $newValueArray = [];
2197
                foreach ($actionCMDs[$key] as $idx => $order) {
2198
                    // Just one reflection here: It is clear that when removing elements from a flexform, then we will get lost
2199
                    // files unless we act on this delete operation by traversing and deleting files that were referred to.
2200
                    if ($order !== 'DELETE') {
2201
                        $newValueArray[$idx] = $valueArray[$idx];
2202
                    }
2203
                    unset($valueArray[$idx]);
2204
                }
2205
                $valueArray += $newValueArray;
2206
            } elseif (is_array($actionCMDs[$key]) && isset($valueArray[$key])) {
2207
                $this->_ACTION_FLEX_FORMdata($valueArray[$key], $actionCMDs[$key]);
2208
            }
2209
        }
2210
    }
2211
2212
    /**
2213
     * Evaluates 'inline' type values.
2214
     * (partly copied from the select_group function on this issue)
2215
     *
2216
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
2217
     * @param string $value The value to set.
2218
     * @param array $tcaFieldConf Field configuration from TCA
2219
     * @param array $PP Additional parameters in a numeric array: $table,$id,$curValue,$status,$realPid,$recFID
2220
     * @param string $field Field name
2221
     * @param array $additionalData Additional data to be forwarded to sub-processors
2222
     * @internal should only be used from within DataHandler
2223
     */
2224
    public function checkValue_inline($res, $value, $tcaFieldConf, $PP, $field, array $additionalData = null)
2225
    {
2226
        [$table, $id, , $status] = $PP;
2227
        $this->checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData);
2228
    }
2229
2230
    /**
2231
     * Evaluates 'inline' type values.
2232
     * (partly copied from the select_group function on this issue)
2233
     *
2234
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
2235
     * @param string $value The value to set.
2236
     * @param array $tcaFieldConf Field configuration from TCA
2237
     * @param string $table Table name
2238
     * @param int $id UID of record
2239
     * @param string $status 'update' or 'new' flag
2240
     * @param string $field Field name
2241
     * @param array $additionalData Additional data to be forwarded to sub-processors
2242
     * @return array|false Modified $res array
2243
     * @internal should only be used from within DataHandler
2244
     */
2245
    public function checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, array $additionalData = null)
2246
    {
2247
        if (!$tcaFieldConf['foreign_table']) {
2248
            // Fatal error, inline fields should always have a foreign_table defined
2249
            return false;
2250
        }
2251
        // When values are sent they come as comma-separated values which are exploded by this function:
2252
        $valueArray = GeneralUtility::trimExplode(',', $value);
2253
        // Remove duplicates: (should not be needed)
2254
        $valueArray = array_unique($valueArray);
2255
        // Example for received data:
2256
        // $value = 45,NEW4555fdf59d154,12,123
2257
        // We need to decide whether we use the stack or can save the relation directly.
2258
        if (!empty($value) && (strpos($value, 'NEW') !== false || !MathUtility::canBeInterpretedAsInteger($id))) {
2259
            $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2260
            $this->addNewValuesToRemapStackChildIds($valueArray);
2261
            $this->remapStack[] = [
2262
                'func' => 'checkValue_inline_processDBdata',
2263
                'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData],
2264
                'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2265
                'additionalData' => $additionalData,
2266
                'field' => $field,
2267
            ];
2268
            unset($res['value']);
2269
        } elseif ($value || MathUtility::canBeInterpretedAsInteger($id)) {
2270
            $res['value'] = $this->checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field);
2271
        }
2272
        return $res;
2273
    }
2274
2275
    /**
2276
     * Checks if a fields has more items than defined via TCA in maxitems.
2277
     * If there are more items than allowed, the item list is truncated to the defined number.
2278
     *
2279
     * @param array $tcaFieldConf Field configuration from TCA
2280
     * @param array $valueArray Current value array of items
2281
     * @return array The truncated value array of items
2282
     * @internal should only be used from within DataHandler
2283
     */
2284
    public function checkValue_checkMax($tcaFieldConf, $valueArray)
2285
    {
2286
        // BTW, checking for min and max items here does NOT make any sense when MM is used because the above function
2287
        // calls will just return an array with a single item (the count) if MM is used... Why didn't I perform the check
2288
        // before? Probably because we could not evaluate the validity of record uids etc... Hmm...
2289
        // NOTE to the comment: It's not really possible to check for too few items, because you must then determine first,
2290
        // if the field is actual used regarding the CType.
2291
        $maxitems = isset($tcaFieldConf['maxitems']) ? (int)$tcaFieldConf['maxitems'] : 99999;
2292
        return array_slice($valueArray, 0, $maxitems);
2293
    }
2294
2295
    /*********************************************
2296
     *
2297
     * Helper functions for evaluation functions.
2298
     *
2299
     ********************************************/
2300
    /**
2301
     * Gets a unique value for $table/$id/$field based on $value
2302
     *
2303
     * @param string $table Table name
2304
     * @param string $field Field name for which $value must be unique
2305
     * @param string $value Value string.
2306
     * @param int $id UID to filter out in the lookup (the record itself...)
2307
     * @param int $newPid If set, the value will be unique for this PID
2308
     * @return string Modified value (if not-unique). Will be the value appended with a number (until 100, then the function just breaks).
2309
     * @todo: consider workspaces, especially when publishing a unique value which has a unique value already in live
2310
     * @internal should only be used from within DataHandler
2311
     */
2312
    public function getUnique($table, $field, $value, $id, $newPid = 0)
2313
    {
2314
        if (!is_array($GLOBALS['TCA'][$table]) || !is_array($GLOBALS['TCA'][$table]['columns'][$field])) {
2315
            // Field is not configured in TCA
2316
            return $value;
2317
        }
2318
2319
        if ((string)$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] === 'exclude') {
2320
            $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
2321
            $l10nParent = (int)$this->checkValue_currentRecord[$transOrigPointerField];
2322
            if ($l10nParent > 0) {
2323
                // Current record is a translation and l10n_mode "exclude" just copies the value from source language
2324
                return $value;
2325
            }
2326
        }
2327
2328
        $newValue = $originalValue = $value;
2329
        $queryBuilder = $this->getUniqueCountStatement($newValue, $table, $field, (int)$id, (int)$newPid);
2330
        // For as long as records with the test-value existing, try again (with incremented numbers appended)
2331
        $statement = $queryBuilder->execute();
2332
        if ($statement->fetchColumn()) {
2333
            for ($counter = 0; $counter <= 100; $counter++) {
2334
                $newValue = $value . $counter;
2335
                if (class_exists(\Doctrine\DBAL\ForwardCompatibility\Result::class) && $statement instanceof \Doctrine\DBAL\ForwardCompatibility\Result) {
0 ignored issues
show
Bug introduced by
The type Doctrine\DBAL\ForwardCompatibility\Result was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
2336
                    $statement = $statement->getIterator();
2337
                }
2338
                $statement->bindValue(1, $newValue);
2339
                $statement->execute();
2340
                if (!$statement->fetchColumn()) {
2341
                    break;
2342
                }
2343
            }
2344
        }
2345
2346
        if ($originalValue !== $newValue) {
2347
            $this->log($table, $id, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::WARNING, 'The value of the field "%s" has been changed from "%s" to "%s" as it is required to be unique.', 1, [$field, $originalValue, $newValue], $newPid);
2348
        }
2349
2350
        return $newValue;
2351
    }
2352
2353
    /**
2354
     * Gets the count of records for a unique field
2355
     *
2356
     * @param string $value The string value which should be unique
2357
     * @param string $table Table name
2358
     * @param string $field Field name for which $value must be unique
2359
     * @param int $uid UID to filter out in the lookup (the record itself...)
2360
     * @param int $pid If set, the value will be unique for this PID
2361
     * @return QueryBuilder Return the prepared statement to check uniqueness
2362
     */
2363
    protected function getUniqueCountStatement(
2364
        string $value,
2365
        string $table,
2366
        string $field,
2367
        int $uid,
2368
        int $pid
2369
    ) {
2370
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
2371
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
2372
        $queryBuilder
2373
            ->count('uid')
2374
            ->from($table)
2375
            ->where(
2376
                $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value)),
2377
                $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter($uid, \PDO::PARAM_INT))
2378
            );
2379
        // ignore translations of current record if field is configured with l10n_mode = "exclude"
2380
        if (($GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude'
2381
            && ($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? '') !== ''
2382
            && ($GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') !== '') {
2383
            $queryBuilder
2384
                ->andWhere(
2385
                    $queryBuilder->expr()->orX(
2386
                    // records without l10n_parent must be taken into account (in any language)
2387
                        $queryBuilder->expr()->eq(
2388
                            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2389
                            $queryBuilder->createPositionalParameter(0, \PDO::PARAM_INT)
2390
                        ),
2391
                        // translations of other records must be taken into account
2392
                        $queryBuilder->expr()->neq(
2393
                            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2394
                            $queryBuilder->createPositionalParameter($uid, \PDO::PARAM_INT)
2395
                        )
2396
                    )
2397
                );
2398
        }
2399
        if ($pid !== 0) {
2400
            $queryBuilder->andWhere(
2401
                $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, \PDO::PARAM_INT))
2402
            );
2403
        } else {
2404
            // pid>=0 for versioning
2405
            $queryBuilder->andWhere(
2406
                $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, \PDO::PARAM_INT))
2407
            );
2408
        }
2409
        return $queryBuilder;
2410
    }
2411
2412
    /**
2413
     * gets all records that have the same value in a field
2414
     * excluding the given uid
2415
     *
2416
     * @param string $tableName Table name
2417
     * @param int $uid UID to filter out in the lookup (the record itself...)
2418
     * @param string $fieldName Field name for which $value must be unique
2419
     * @param string $value Value string.
2420
     * @param int $pageId If set, the value will be unique for this PID
2421
     * @return array
2422
     * @internal should only be used from within DataHandler
2423
     */
2424
    public function getRecordsWithSameValue($tableName, $uid, $fieldName, $value, $pageId = 0)
2425
    {
2426
        $result = [];
2427
        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
2428
            return $result;
2429
        }
2430
2431
        $uid = (int)$uid;
2432
        $pageId = (int)$pageId;
2433
2434
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
2435
        $queryBuilder->getRestrictions()
2436
            ->removeAll()
2437
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
2438
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
2439
2440
        $queryBuilder->select('*')
2441
            ->from($tableName)
2442
            ->where(
2443
                $queryBuilder->expr()->eq(
2444
                    $fieldName,
2445
                    $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR)
2446
                ),
2447
                $queryBuilder->expr()->neq(
2448
                    'uid',
2449
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
2450
                )
2451
            );
2452
2453
        if ($pageId) {
2454
            $queryBuilder->andWhere(
2455
                $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT))
2456
            );
2457
        }
2458
2459
        $result = $queryBuilder->execute()->fetchAll();
2460
2461
        return $result;
2462
    }
2463
2464
    /**
2465
     * @param string $value The field value to be evaluated
2466
     * @param array $evalArray Array of evaluations to traverse.
2467
     * @param string $is_in The "is_in" value of the field configuration from TCA
2468
     * @return array
2469
     * @internal should only be used from within DataHandler
2470
     */
2471
    public function checkValue_text_Eval($value, $evalArray, $is_in)
2472
    {
2473
        $res = [];
2474
        $set = true;
2475
        foreach ($evalArray as $func) {
2476
            switch ($func) {
2477
                case 'trim':
2478
                    $value = trim($value);
2479
                    break;
2480
                case 'required':
2481
                    if (!$value) {
2482
                        $set = false;
2483
                    }
2484
                    break;
2485
                default:
2486
                    if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2487
                        if (class_exists($func)) {
2488
                            $evalObj = GeneralUtility::makeInstance($func);
2489
                            if (method_exists($evalObj, 'evaluateFieldValue')) {
2490
                                $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2491
                            }
2492
                        }
2493
                    }
2494
            }
2495
        }
2496
        if ($set) {
2497
            $res['value'] = $value;
2498
        }
2499
        return $res;
2500
    }
2501
2502
    /**
2503
     * Evaluation of 'input'-type values based on 'eval' list
2504
     *
2505
     * @param string $value Value to evaluate
2506
     * @param array $evalArray Array of evaluations to traverse.
2507
     * @param string $is_in Is-in string for 'is_in' evaluation
2508
     * @param string $table Table name the eval is evaluated on
2509
     * @param string|int $id Record ID the eval is evaluated on
2510
     * @return array Modified $value in key 'value' or empty array
2511
     * @internal should only be used from within DataHandler
2512
     */
2513
    public function checkValue_input_Eval($value, $evalArray, $is_in, string $table = '', $id = ''): array
2514
    {
2515
        $res = [];
2516
        $set = true;
2517
        foreach ($evalArray as $func) {
2518
            switch ($func) {
2519
                case 'int':
2520
                case 'year':
2521
                    $value = (int)$value;
2522
                    break;
2523
                case 'time':
2524
                case 'timesec':
2525
                    // If $value is a pure integer we have the number of seconds, we can store that directly
2526
                    if ($value !== '' && !MathUtility::canBeInterpretedAsInteger($value)) {
2527
                        // $value is an ISO 8601 date
2528
                        $value = (new \DateTime($value))->getTimestamp();
2529
                    }
2530
                    break;
2531
                case 'date':
2532
                case 'datetime':
2533
                    // If $value is a pure integer we have the number of seconds, we can store that directly
2534
                    if ($value !== null && $value !== '' && !MathUtility::canBeInterpretedAsInteger($value)) {
2535
                        // The value we receive from JS is an ISO 8601 date, which is always in UTC. (the JS code works like that, on purpose!)
2536
                        // For instance "1999-11-11T11:11:11Z"
2537
                        // Since the user actually specifies the time in the server's local time, we need to mangle this
2538
                        // to reflect the server TZ. So we make this 1999-11-11T11:11:11+0200 (assuming Europe/Vienna here)
2539
                        // In the database we store the date in UTC (1999-11-11T09:11:11Z), hence we take the timestamp of this converted value.
2540
                        // For achieving this we work with timestamps only (which are UTC) and simply adjust it for the
2541
                        // TZ difference.
2542
                        try {
2543
                            // Make the date from JS a timestamp
2544
                            $value = (new \DateTime($value))->getTimestamp();
2545
                        } catch (\Exception $e) {
2546
                            // set the default timezone value to achieve the value of 0 as a result
2547
                            $value = (int)date('Z', 0);
2548
                        }
2549
2550
                        // @todo this hacky part is problematic when it comes to times around DST switch! Add test to prove that this is broken.
2551
                        $value -= date('Z', $value);
2552
                    }
2553
                    break;
2554
                case 'double2':
2555
                    $value = preg_replace('/[^0-9,\\.-]/', '', $value);
2556
                    $negative = $value[0] === '-';
2557
                    $value = strtr($value, [',' => '.', '-' => '']);
2558
                    if (strpos($value, '.') === false) {
2559
                        $value .= '.0';
2560
                    }
2561
                    $valueArray = explode('.', $value);
2562
                    $dec = array_pop($valueArray);
2563
                    $value = implode('', $valueArray) . '.' . $dec;
2564
                    if ($negative) {
2565
                        $value *= -1;
2566
                    }
2567
                    $value = number_format($value, 2, '.', '');
0 ignored issues
show
Bug introduced by
$value of type string is incompatible with the type double expected by parameter $num of number_format(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2567
                    $value = number_format(/** @scrutinizer ignore-type */ $value, 2, '.', '');
Loading history...
2568
                    break;
2569
                case 'md5':
2570
                    if (strlen($value) !== 32) {
2571
                        $set = false;
2572
                    }
2573
                    break;
2574
                case 'trim':
2575
                    $value = trim($value);
2576
                    break;
2577
                case 'upper':
2578
                    $value = mb_strtoupper($value, 'utf-8');
2579
                    break;
2580
                case 'lower':
2581
                    $value = mb_strtolower($value, 'utf-8');
2582
                    break;
2583
                case 'required':
2584
                    if (!isset($value) || $value === '') {
2585
                        $set = false;
2586
                    }
2587
                    break;
2588
                case 'is_in':
2589
                    $c = mb_strlen($value);
2590
                    if ($c) {
2591
                        $newVal = '';
2592
                        for ($a = 0; $a < $c; $a++) {
2593
                            $char = mb_substr($value, $a, 1);
2594
                            if (mb_strpos($is_in, $char) !== false) {
2595
                                $newVal .= $char;
2596
                            }
2597
                        }
2598
                        $value = $newVal;
2599
                    }
2600
                    break;
2601
                case 'nospace':
2602
                    $value = str_replace(' ', '', $value);
2603
                    break;
2604
                case 'alpha':
2605
                    $value = preg_replace('/[^a-zA-Z]/', '', $value);
2606
                    break;
2607
                case 'num':
2608
                    $value = preg_replace('/[^0-9]/', '', $value);
2609
                    break;
2610
                case 'alphanum':
2611
                    $value = preg_replace('/[^a-zA-Z0-9]/', '', $value);
2612
                    break;
2613
                case 'alphanum_x':
2614
                    $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
2615
                    break;
2616
                case 'domainname':
2617
                    if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) {
2618
                        $value = (string)idn_to_ascii($value);
2619
                    }
2620
                    break;
2621
                case 'email':
2622
                    if ((string)$value !== '') {
2623
                        $this->checkValue_input_ValidateEmail($value, $set, $table, $id);
2624
                    }
2625
                    break;
2626
                case 'saltedPassword':
2627
                    // An incoming value is either the salted password if the user did not change existing password
2628
                    // when submitting the form, or a plaintext new password that needs to be turned into a salted password now.
2629
                    // The strategy is to see if a salt instance can be created from the incoming value. If so,
2630
                    // no new password was submitted and we keep the value. If no salting instance can be created,
2631
                    // incoming value must be a new plain text value that needs to be hashed.
2632
                    $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
2633
                    $mode = $table === 'fe_users' ? 'FE' : 'BE';
2634
                    try {
2635
                        $hashFactory->get($value, $mode);
2636
                    } catch (InvalidPasswordHashException $e) {
2637
                        // We got no salted password instance, incoming value must be a new plaintext password
2638
                        // Get an instance of the current configured salted password strategy and hash the value
2639
                        $newHashInstance = $hashFactory->getDefaultHashInstance($mode);
2640
                        $value = $newHashInstance->getHashedPassword($value);
2641
                    }
2642
                    break;
2643
                default:
2644
                    if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2645
                        if (class_exists($func)) {
2646
                            $evalObj = GeneralUtility::makeInstance($func);
2647
                            if (method_exists($evalObj, 'evaluateFieldValue')) {
2648
                                $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2649
                            }
2650
                        }
2651
                    }
2652
            }
2653
        }
2654
        if ($set) {
2655
            $res['value'] = $value;
2656
        }
2657
        return $res;
2658
    }
2659
2660
    /**
2661
     * If $value is not a valid e-mail address,
2662
     * $set will be set to false and a flash error
2663
     * message will be added
2664
     *
2665
     * @param string $value Value to evaluate
2666
     * @param bool $set TRUE if an update should be done
2667
     * @throws \InvalidArgumentException
2668
     * @throws \TYPO3\CMS\Core\Exception
2669
     */
2670
    protected function checkValue_input_ValidateEmail($value, &$set, string $table, $id)
2671
    {
2672
        if (GeneralUtility::validEmail($value)) {
2673
            return;
2674
        }
2675
2676
        $set = false;
2677
        $this->log(
2678
            $table,
2679
            $id,
2680
            SystemLogDatabaseAction::UPDATE,
2681
            0,
2682
            SystemLogErrorClassification::SECURITY_NOTICE,
2683
            '"%s" is not a valid e-mail address.',
2684
            -1,
2685
            [$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.invalidEmail'), $value]
2686
        );
2687
    }
2688
2689
    /**
2690
     * Returns data for group/db and select fields
2691
     *
2692
     * @param array $valueArray Current value array
2693
     * @param array $tcaFieldConf TCA field config
2694
     * @param int $id Record id, used for look-up of MM relations (local_uid)
2695
     * @param string $status Status string ('update' or 'new')
2696
     * @param string $type The type, either 'select', 'group' or 'inline'
2697
     * @param string $currentTable Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler
2698
     * @param string $currentField field name, needs to be set for writing to sys_history
2699
     * @return array Modified value array
2700
     * @internal should only be used from within DataHandler
2701
     */
2702
    public function checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField)
2703
    {
2704
        $tables = $type === 'group' ? $tcaFieldConf['allowed'] : $tcaFieldConf['foreign_table'];
2705
        $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : '';
2706
        $newRelations = implode(',', $valueArray);
2707
        /** @var RelationHandler $dbAnalysis */
2708
        $dbAnalysis = $this->createRelationHandlerInstance();
2709
        $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2710
        $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf);
2711
        if ($tcaFieldConf['MM'] ?? false) {
2712
            // convert submitted items to use version ids instead of live ids
2713
            // (only required for MM relations in a workspace context)
2714
            $dbAnalysis->convertItemArray();
2715
            if ($status === 'update') {
2716
                /** @var RelationHandler $oldRelations_dbAnalysis */
2717
                $oldRelations_dbAnalysis = $this->createRelationHandlerInstance();
2718
                $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2719
                // Db analysis with $id will initialize with the existing relations
2720
                $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf);
2721
                $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray());
2722
                $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, $prep);
0 ignored issues
show
Bug introduced by
It seems like $prep can also be of type string; however, parameter $prependTableName of TYPO3\CMS\Core\Database\RelationHandler::writeMM() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2722
                $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, /** @scrutinizer ignore-type */ $prep);
Loading history...
2723
                if ($oldRelations != $newRelations) {
2724
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
2725
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
2726
                } else {
2727
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
2728
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
2729
                }
2730
            } else {
2731
                $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
2732
            }
2733
            $valueArray = $dbAnalysis->countItems();
2734
        } else {
2735
            $valueArray = $dbAnalysis->getValueArray($prep);
0 ignored issues
show
Bug introduced by
It seems like $prep can also be of type string; however, parameter $prependTableName of TYPO3\CMS\Core\Database\...andler::getValueArray() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

2735
            $valueArray = $dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prep);
Loading history...
2736
        }
2737
        // Here we should see if 1) the records exist anymore, 2) which are new and check if the BE_USER has read-access to the new ones.
2738
        return $valueArray;
2739
    }
2740
2741
    /**
2742
     * Explodes the $value, which is a list of files/uids (group select)
2743
     *
2744
     * @param string $value Input string, comma separated values. For each part it will also be detected if a '|' is found and the first part will then be used if that is the case. Further the value will be rawurldecoded.
2745
     * @return array The value array.
2746
     * @internal should only be used from within DataHandler
2747
     */
2748
    public function checkValue_group_select_explodeSelectGroupValue($value)
2749
    {
2750
        $valueArray = GeneralUtility::trimExplode(',', $value, true);
2751
        foreach ($valueArray as &$newVal) {
2752
            $temp = explode('|', $newVal, 2);
2753
            $newVal = str_replace(['|', ','], '', rawurldecode($temp[0]));
2754
        }
2755
        unset($newVal);
2756
        return $valueArray;
2757
    }
2758
2759
    /**
2760
     * Starts the processing the input data for flexforms. This will traverse all sheets / languages and for each it will traverse the sub-structure.
2761
     * See checkValue_flex_procInData_travDS() for more details.
2762
     * WARNING: Currently, it traverses based on the actual _data_ array and NOT the _structure_. This means that values for non-valid fields, lKey/vKey/sKeys will be accepted! For traversal of data with a call back function you should rather use \TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools
2763
     *
2764
     * @param array $dataPart The 'data' part of the INPUT flexform data
2765
     * @param array $dataPart_current The 'data' part of the CURRENT flexform data
2766
     * @param array $uploadedFiles The uploaded files for the 'data' part of the INPUT flexform data
2767
     * @param array $dataStructure Data structure for the form (might be sheets or not). Only values in the data array which has a configuration in the data structure will be processed.
2768
     * @param array $pParams A set of parameters to pass through for the calling of the evaluation functions
2769
     * @param string $callBackFunc Optional call back function, see checkValue_flex_procInData_travDS()  DEPRECATED, use \TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools instead for traversal!
2770
     * @param array $workspaceOptions
2771
     * @return array The modified 'data' part.
2772
     * @see checkValue_flex_procInData_travDS()
2773
     * @internal should only be used from within DataHandler
2774
     */
2775
    public function checkValue_flex_procInData($dataPart, $dataPart_current, $uploadedFiles, $dataStructure, $pParams, $callBackFunc = '', array $workspaceOptions = [])
2776
    {
2777
        if (is_array($dataPart)) {
0 ignored issues
show
introduced by
The condition is_array($dataPart) is always true.
Loading history...
2778
            foreach ($dataPart as $sKey => $sheetDef) {
2779
                if (isset($dataStructure['sheets'][$sKey]) && is_array($dataStructure['sheets'][$sKey]) && is_array($sheetDef)) {
2780
                    foreach ($sheetDef as $lKey => $lData) {
2781
                        $this->checkValue_flex_procInData_travDS(
2782
                            $dataPart[$sKey][$lKey],
2783
                            $dataPart_current[$sKey][$lKey] ?? null,
2784
                            $uploadedFiles[$sKey][$lKey] ?? null,
2785
                            $dataStructure['sheets'][$sKey]['ROOT']['el'] ?? null,
2786
                            $pParams,
2787
                            $callBackFunc,
2788
                            $sKey . '/' . $lKey . '/',
2789
                            $workspaceOptions
2790
                        );
2791
                    }
2792
                }
2793
            }
2794
        }
2795
        return $dataPart;
2796
    }
2797
2798
    /**
2799
     * Processing of the sheet/language data array
2800
     * When it finds a field with a value the processing is done by ->checkValue_SW() by default but if a call back function name is given that method in this class will be called for the processing instead.
2801
     *
2802
     * @param array $dataValues New values (those being processed): Multidimensional Data array for sheet/language, passed by reference!
2803
     * @param array $dataValues_current Current values: Multidimensional Data array. May be empty array() if not needed (for callBackFunctions)
2804
     * @param array $uploadedFiles Uploaded files array for sheet/language. May be empty array() if not needed (for callBackFunctions)
2805
     * @param array $DSelements Data structure which fits the data array
2806
     * @param array $pParams A set of parameters to pass through for the calling of the evaluation functions / call back function
2807
     * @param string $callBackFunc Call back function, default is checkValue_SW(). If $this->callBackObj is set to an object, the callback function in that object is called instead.
2808
     * @param string $structurePath
2809
     * @param array $workspaceOptions
2810
     * @see checkValue_flex_procInData()
2811
     * @internal should only be used from within DataHandler
2812
     */
2813
    public function checkValue_flex_procInData_travDS(&$dataValues, $dataValues_current, $uploadedFiles, $DSelements, $pParams, $callBackFunc, $structurePath, array $workspaceOptions = [])
2814
    {
2815
        if (!is_array($DSelements)) {
0 ignored issues
show
introduced by
The condition is_array($DSelements) is always true.
Loading history...
2816
            return;
2817
        }
2818
2819
        // For each DS element:
2820
        foreach ($DSelements as $key => $dsConf) {
2821
            // Array/Section:
2822
            if (isset($DSelements[$key]['type']) && $DSelements[$key]['type'] === 'array') {
2823
                if (!is_array($dataValues[$key]['el'])) {
2824
                    continue;
2825
                }
2826
2827
                if ($DSelements[$key]['section']) {
2828
                    foreach ($dataValues[$key]['el'] as $ik => $el) {
2829
                        if (!is_array($el)) {
2830
                            continue;
2831
                        }
2832
2833
                        if (!is_array($dataValues_current[$key]['el'])) {
2834
                            $dataValues_current[$key]['el'] = [];
2835
                        }
2836
                        $theKey = key($el);
2837
                        if (!is_array($dataValues[$key]['el'][$ik][$theKey]['el'])) {
2838
                            continue;
2839
                        }
2840
2841
                        $this->checkValue_flex_procInData_travDS($dataValues[$key]['el'][$ik][$theKey]['el'], is_array($dataValues_current[$key]['el'][$ik]) ? $dataValues_current[$key]['el'][$ik][$theKey]['el'] : [], $uploadedFiles[$key]['el'][$ik][$theKey]['el'], $DSelements[$key]['el'][$theKey]['el'], $pParams, $callBackFunc, $structurePath . $key . '/el/' . $ik . '/' . $theKey . '/el/', $workspaceOptions);
2842
                    }
2843
                } else {
2844
                    if (!isset($dataValues[$key]['el'])) {
2845
                        $dataValues[$key]['el'] = [];
2846
                    }
2847
                    $this->checkValue_flex_procInData_travDS($dataValues[$key]['el'], $dataValues_current[$key]['el'], $uploadedFiles[$key]['el'], $DSelements[$key]['el'], $pParams, $callBackFunc, $structurePath . $key . '/el/', $workspaceOptions);
2848
                }
2849
            } else {
2850
                // When having no specific sheets, it's "TCEforms.config", when having a sheet, it's just "config"
2851
                $fieldConfiguration = $dsConf['TCEforms']['config'] ?? $dsConf['config'] ?? null;
2852
                // init with value from config for passthrough fields
2853
                if (!empty($fieldConfiguration['type']) && $fieldConfiguration['type'] === 'passthrough') {
2854
                    if (!empty($dataValues_current[$key]['vDEF'])) {
2855
                        // If there is existing value, keep it
2856
                        $dataValues[$key]['vDEF'] = $dataValues_current[$key]['vDEF'];
2857
                    } elseif (
2858
                        !empty($fieldConfiguration['default'])
2859
                        && isset($pParams[1])
2860
                        && !MathUtility::canBeInterpretedAsInteger($pParams[1])
2861
                    ) {
2862
                        // If is new record and a default is specified for field, use it.
2863
                        $dataValues[$key]['vDEF'] = $fieldConfiguration['default'];
2864
                    }
2865
                }
2866
                if (!is_array($fieldConfiguration) || !isset($dataValues[$key]) || !is_array($dataValues[$key])) {
2867
                    continue;
2868
                }
2869
2870
                foreach ($dataValues[$key] as $vKey => $data) {
2871
                    if ($callBackFunc) {
2872
                        if (is_object($this->callBackObj)) {
2873
                            $res = $this->callBackObj->{$callBackFunc}($pParams, $fieldConfiguration, $dataValues[$key][$vKey], $dataValues_current[$key][$vKey], $uploadedFiles[$key][$vKey], $structurePath . $key . '/' . $vKey . '/', $workspaceOptions);
2874
                        } else {
2875
                            $res = $this->{$callBackFunc}(
2876
                                $pParams,
2877
                                $fieldConfiguration,
2878
                                $dataValues[$key][$vKey] ?? null,
2879
                                $dataValues_current[$key][$vKey] ?? null,
2880
                                $uploadedFiles[$key][$vKey] ?? null,
2881
                                $structurePath . $key . '/' . $vKey . '/',
2882
                                $workspaceOptions
2883
                            );
2884
                        }
2885
                    } else {
2886
                        // Default
2887
                        [$CVtable, $CVid, $CVcurValue, $CVstatus, $CVrealPid, $CVrecFID, $CVtscPID] = $pParams;
2888
2889
                        $additionalData = [
2890
                            'flexFormId' => $CVrecFID,
2891
                            'flexFormPath' => trim(rtrim($structurePath, '/') . '/' . $key . '/' . $vKey, '/'),
2892
                        ];
2893
2894
                        $res = $this->checkValue_SW(
2895
                            [],
2896
                            $dataValues[$key][$vKey] ?? null,
2897
                            $fieldConfiguration,
2898
                            $CVtable,
2899
                            $CVid,
2900
                            $dataValues_current[$key][$vKey] ?? null,
2901
                            $CVstatus,
2902
                            $CVrealPid,
2903
                            $CVrecFID,
2904
                            '',
2905
                            $uploadedFiles[$key][$vKey] ?? null,
2906
                            $CVtscPID,
2907
                            $additionalData
2908
                        );
2909
                    }
2910
                    // Adding the value:
2911
                    if (isset($res['value'])) {
2912
                        $dataValues[$key][$vKey] = $res['value'];
2913
                    }
2914
                    // Finally, check if new and old values are different (or no .vDEFbase value is found) and if so, we record the vDEF value for diff'ing.
2915
                    // We do this after $dataValues has been updated since I expect that $dataValues_current holds evaluated values from database (so this must be the right value to compare with).
2916
                    if (mb_substr($vKey, -9) !== '.vDEFbase') {
2917
                        if (($GLOBALS['TYPO3_CONF_VARS']['BE']['flexFormXMLincludeDiffBase'] ?? false)
2918
                            && $vKey !== 'vDEF'
2919
                            && ((string)$dataValues[$key][$vKey] !== (string)$dataValues_current[$key][$vKey] || !isset($dataValues_current[$key][$vKey . '.vDEFbase']))
2920
                        ) {
2921
                            // Now, check if a vDEF value is submitted in the input data, if so we expect this has been processed prior to this operation (normally the case since those fields are higher in the form) and we can use that:
2922
                            if (isset($dataValues[$key]['vDEF'])) {
2923
                                $diffValue = $dataValues[$key]['vDEF'];
2924
                            } else {
2925
                                // If not found (for translators with no access to the default language) we use the one from the current-value data set:
2926
                                $diffValue = $dataValues_current[$key]['vDEF'];
2927
                            }
2928
                            // Setting the reference value for vDEF for this translation. This will be used for translation tools to make a diff between the vDEF and vDEFbase to see if an update would be fitting.
2929
                            $dataValues[$key][$vKey . '.vDEFbase'] = $diffValue;
2930
                        }
2931
                    }
2932
                }
2933
            }
2934
        }
2935
    }
2936
2937
    /**
2938
     * Returns data for inline fields.
2939
     *
2940
     * @param array $valueArray Current value array
2941
     * @param array $tcaFieldConf TCA field config
2942
     * @param int $id Record id
2943
     * @param string $status Status string ('update' or 'new')
2944
     * @param string $table Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler
2945
     * @param string $field The current field the values are modified for
2946
     * @return string Modified values
2947
     */
2948
    protected function checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field)
2949
    {
2950
        $foreignTable = $tcaFieldConf['foreign_table'];
2951
        $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray);
2952
        // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
2953
        /** @var RelationHandler $dbAnalysis */
2954
        $dbAnalysis = $this->createRelationHandlerInstance();
2955
        $dbAnalysis->start(implode(',', $valueArray), $foreignTable, '', 0, $table, $tcaFieldConf);
2956
        // IRRE with a pointer field (database normalization):
2957
        if ($tcaFieldConf['foreign_field'] ?? false) {
2958
            // if the record was imported, sorting was also imported, so skip this
2959
            $skipSorting = (bool)$this->callFromImpExp;
2960
            // update record in intermediate table (sorting & pointer uid to parent record)
2961
            $dbAnalysis->writeForeignField($tcaFieldConf, $id, 0, $skipSorting);
2962
            $newValue = $dbAnalysis->countItems(false);
2963
        } elseif ($this->getInlineFieldType($tcaFieldConf) === 'mm') {
2964
            // In order to fully support all the MM stuff, directly call checkValue_group_select_processDBdata instead of repeating the needed code here
2965
            $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, 'select', $table, $field);
2966
            $newValue = $valueArray[0];
2967
        } else {
2968
            $valueArray = $dbAnalysis->getValueArray();
2969
            // Checking that the number of items is correct:
2970
            $valueArray = $this->checkValue_checkMax($tcaFieldConf, $valueArray);
2971
            $newValue = $this->castReferenceValue(implode(',', $valueArray), $tcaFieldConf);
2972
        }
2973
        return $newValue;
2974
    }
2975
2976
    /*********************************************
2977
     *
2978
     * PROCESSING COMMANDS
2979
     *
2980
     ********************************************/
2981
    /**
2982
     * Processing the cmd-array
2983
     * See "TYPO3 Core API" for a description of the options.
2984
     *
2985
     * @return void|bool
2986
     */
2987
    public function process_cmdmap()
2988
    {
2989
        // Editing frozen:
2990
        if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
2991
            $this->newlog('All editing in this workspace has been frozen!', SystemLogErrorClassification::USER_ERROR);
2992
            return false;
2993
        }
2994
        // Hook initialization:
2995
        $hookObjectsArr = [];
2996
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
2997
            $hookObj = GeneralUtility::makeInstance($className);
2998
            if (method_exists($hookObj, 'processCmdmap_beforeStart')) {
2999
                $hookObj->processCmdmap_beforeStart($this);
3000
            }
3001
            $hookObjectsArr[] = $hookObj;
3002
        }
3003
        $pasteDatamap = [];
3004
        // Traverse command map:
3005
        foreach ($this->cmdmap as $table => $_) {
3006
            // Check if the table may be modified!
3007
            $modifyAccessList = $this->checkModifyAccessList($table);
3008
            if (!$modifyAccessList) {
3009
                $this->log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table \'%s\' without permission', 1, [$table]);
3010
            }
3011
            // Check basic permissions and circumstances:
3012
            if (!isset($GLOBALS['TCA'][$table]) || $this->tableReadOnly($table) || !is_array($this->cmdmap[$table]) || !$modifyAccessList) {
3013
                continue;
3014
            }
3015
3016
            // Traverse the command map:
3017
            foreach ($this->cmdmap[$table] as $id => $incomingCmdArray) {
3018
                if (!is_array($incomingCmdArray)) {
3019
                    continue;
3020
                }
3021
3022
                if ($table === 'pages') {
3023
                    // for commands on pages do a pagetree-refresh
3024
                    $this->pagetreeNeedsRefresh = true;
3025
                }
3026
3027
                foreach ($incomingCmdArray as $command => $value) {
3028
                    $pasteUpdate = false;
3029
                    if (is_array($value) && isset($value['action']) && $value['action'] === 'paste') {
3030
                        // Extended paste command: $command is set to "move" or "copy"
3031
                        // $value['update'] holds field/value pairs which should be updated after copy/move operation
3032
                        // $value['target'] holds original $value (target of move/copy)
3033
                        $pasteUpdate = $value['update'];
3034
                        $value = $value['target'];
3035
                    }
3036
                    foreach ($hookObjectsArr as $hookObj) {
3037
                        if (method_exists($hookObj, 'processCmdmap_preProcess')) {
3038
                            $hookObj->processCmdmap_preProcess($command, $table, $id, $value, $this, $pasteUpdate);
3039
                        }
3040
                    }
3041
                    // Init copyMapping array:
3042
                    // Must clear this array before call from here to those functions:
3043
                    // Contains mapping information between new and old id numbers.
3044
                    $this->copyMappingArray = [];
3045
                    // process the command
3046
                    $commandIsProcessed = false;
3047
                    foreach ($hookObjectsArr as $hookObj) {
3048
                        if (method_exists($hookObj, 'processCmdmap')) {
3049
                            $hookObj->processCmdmap($command, $table, $id, $value, $commandIsProcessed, $this, $pasteUpdate);
3050
                        }
3051
                    }
3052
                    // Only execute default commands if a hook hasn't been processed the command already
3053
                    if (!$commandIsProcessed) {
3054
                        $procId = $id;
3055
                        $backupUseTransOrigPointerField = $this->useTransOrigPointerField;
3056
                        // Branch, based on command
3057
                        switch ($command) {
3058
                            case 'move':
3059
                                $this->moveRecord($table, (int)$id, $value);
3060
                                break;
3061
                            case 'copy':
3062
                                $target = $value['target'] ?? $value;
3063
                                $ignoreLocalization = (bool)($value['ignoreLocalization'] ?? false);
3064
                                if ($table === 'pages') {
3065
                                    $this->copyPages((int)$id, $target);
3066
                                } else {
3067
                                    $this->copyRecord($table, (int)$id, $target, true, [], '', 0, $ignoreLocalization);
3068
                                }
3069
                                $procId = $this->copyMappingArray[$table][$id];
3070
                                break;
3071
                            case 'localize':
3072
                                $this->useTransOrigPointerField = true;
3073
                                $this->localize($table, (int)$id, $value);
3074
                                break;
3075
                            case 'copyToLanguage':
3076
                                $this->useTransOrigPointerField = false;
3077
                                $this->localize($table, (int)$id, $value);
3078
                                break;
3079
                            case 'inlineLocalizeSynchronize':
3080
                                $this->inlineLocalizeSynchronize($table, (int)$id, $value);
3081
                                break;
3082
                            case 'delete':
3083
                                $this->deleteAction($table, (int)$id);
3084
                                break;
3085
                            case 'undelete':
3086
                                $this->undeleteRecord((string)$table, (int)$id);
3087
                                break;
3088
                        }
3089
                        $this->useTransOrigPointerField = $backupUseTransOrigPointerField;
3090
                        if (is_array($pasteUpdate)) {
3091
                            $pasteDatamap[$table][$procId] = $pasteUpdate;
3092
                        }
3093
                    }
3094
                    foreach ($hookObjectsArr as $hookObj) {
3095
                        if (method_exists($hookObj, 'processCmdmap_postProcess')) {
3096
                            $hookObj->processCmdmap_postProcess($command, $table, $id, $value, $this, $pasteUpdate, $pasteDatamap);
3097
                        }
3098
                    }
3099
                    // Merging the copy-array info together for remapping purposes.
3100
                    ArrayUtility::mergeRecursiveWithOverrule($this->copyMappingArray_merged, $this->copyMappingArray);
3101
                }
3102
            }
3103
        }
3104
        /** @var DataHandler $copyTCE */
3105
        $copyTCE = $this->getLocalTCE();
3106
        $copyTCE->start($pasteDatamap, [], $this->BE_USER);
3107
        $copyTCE->process_datamap();
3108
        $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3109
        unset($copyTCE);
3110
3111
        // Finally, before exit, check if there are ID references to remap.
3112
        // This might be the case if versioning or copying has taken place!
3113
        $this->remapListedDBRecords();
3114
        $this->processRemapStack();
3115
        foreach ($hookObjectsArr as $hookObj) {
3116
            if (method_exists($hookObj, 'processCmdmap_afterFinish')) {
3117
                $hookObj->processCmdmap_afterFinish($this);
3118
            }
3119
        }
3120
        if ($this->isOuterMostInstance()) {
3121
            $this->referenceIndexUpdater->update();
3122
            $this->processClearCacheQueue();
3123
            $this->resetNestedElementCalls();
3124
        }
3125
    }
3126
3127
    /*********************************************
3128
     *
3129
     * Cmd: Copying
3130
     *
3131
     ********************************************/
3132
    /**
3133
     * Copying a single record
3134
     *
3135
     * @param string $table Element table
3136
     * @param int $uid Element UID
3137
     * @param int $destPid >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
3138
     * @param bool $first Is a flag set, if the record copied is NOT a 'slave' to another record copied. That is, if this record was asked to be copied in the cmd-array
3139
     * @param array $overrideValues Associative array with field/value pairs to override directly. Notice; Fields must exist in the table record and NOT be among excluded fields!
3140
     * @param string $excludeFields Commalist of fields to exclude from the copy process (might get default values)
3141
     * @param int $language Language ID (from sys_language table)
3142
     * @param bool $ignoreLocalization If TRUE, any localization routine is skipped
3143
     * @return int|null ID of new record, if any
3144
     * @internal should only be used from within DataHandler
3145
     */
3146
    public function copyRecord($table, $uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '', $language = 0, $ignoreLocalization = false)
3147
    {
3148
        $uid = ($origUid = (int)$uid);
3149
        // Only copy if the table is defined in $GLOBALS['TCA'], a uid is given and the record wasn't copied before:
3150
        if (empty($GLOBALS['TCA'][$table]) || $uid === 0) {
3151
            return null;
3152
        }
3153
        if ($this->isRecordCopied($table, $uid)) {
3154
            return null;
3155
        }
3156
3157
        // Fetch record with permission check
3158
        $row = $this->recordInfoWithPermissionCheck($table, $uid, Permission::PAGE_SHOW);
3159
3160
        // This checks if the record can be selected which is all that a copy action requires.
3161
        if ($row === false) {
3162
            $this->log($table, $uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "%s:%s" which does not exist or you do not have permission to read', -1, [$table, $uid]);
3163
            return null;
3164
        }
3165
3166
        // NOT using \TYPO3\CMS\Backend\Utility\BackendUtility::getTSCpid() because we need the real pid - not the ID of a page, if the input is a page...
3167
        $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, $uid, $destPid);
3168
3169
        // Check if table is allowed on destination page
3170
        if (!$this->isTableAllowedForThisPage($tscPID, $table)) {
3171
            $this->log($table, $uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record "%s:%s" on a page (%s) that can\'t store record type.', -1, [$table, $uid, $tscPID]);
3172
            return null;
3173
        }
3174
3175
        $fullLanguageCheckNeeded = $table !== 'pages';
3176
        // Used to check language and general editing rights
3177
        if (!$ignoreLocalization && ($language <= 0 || !$this->BE_USER->checkLanguageAccess($language)) && !$this->BE_USER->recordEditAccessInternals($table, $uid, false, false, $fullLanguageCheckNeeded)) {
3178
            $this->log($table, $uid, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy record "%s:%s" without having permissions to do so. [' . $this->BE_USER->errorMsg . '].', -1, [$table, $uid]);
3179
            return null;
3180
        }
3181
3182
        $data = [];
3183
        $nonFields = array_unique(GeneralUtility::trimExplode(',', 'uid,perms_userid,perms_groupid,perms_user,perms_group,perms_everybody,t3ver_oid,t3ver_wsid,t3ver_state,t3ver_stage,' . $excludeFields, true));
3184
        BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace);
3185
        $row = BackendUtility::purgeComputedPropertiesFromRecord($row);
3186
3187
        // Initializing:
3188
        $theNewID = StringUtility::getUniqueId('NEW');
3189
        $enableField = isset($GLOBALS['TCA'][$table]['ctrl']['enablecolumns']) ? $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'] : '';
3190
        $headerField = $GLOBALS['TCA'][$table]['ctrl']['label'];
3191
        // Getting "copy-after" fields if applicable:
3192
        $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields($table, $uid, abs($destPid), false) : [];
0 ignored issues
show
Bug introduced by
It seems like abs($destPid) can also be of type double; however, parameter $prevUid of TYPO3\CMS\Core\DataHandl...ixCopyAfterDuplFields() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

3192
        $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields($table, $uid, /** @scrutinizer ignore-type */ abs($destPid), false) : [];
Loading history...
3193
        // Page TSconfig related:
3194
        $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
3195
        $tE = $this->getTableEntries($table, $TSConfig);
3196
        // Traverse ALL fields of the selected record:
3197
        foreach ($row as $field => $value) {
3198
            if (!in_array($field, $nonFields, true)) {
3199
                // Get TCA configuration for the field:
3200
                $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? [];
3201
                // Preparation/Processing of the value:
3202
                // "pid" is hardcoded of course:
3203
                // isset() won't work here, since values can be NULL in each of the arrays
3204
                // except setDefaultOnCopyArray, since we exploded that from a string
3205
                if ($field === 'pid') {
3206
                    $value = $destPid;
3207
                } elseif (array_key_exists($field, $overrideValues)) {
3208
                    // Override value...
3209
                    $value = $overrideValues[$field];
3210
                } elseif (array_key_exists($field, $copyAfterFields)) {
3211
                    // Copy-after value if available:
3212
                    $value = $copyAfterFields[$field];
3213
                } else {
3214
                    // Hide at copy may override:
3215
                    if ($first && $field == $enableField
3216
                        && ($GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
3217
                        && !$this->neverHideAtCopy
3218
                        && !($tE['disableHideAtCopy'] ?? false)
3219
                    ) {
3220
                        $value = 1;
3221
                    }
3222
                    // Prepend label on copy:
3223
                    if ($first && $field == $headerField
3224
                        && ($GLOBALS['TCA'][$table]['ctrl']['prependAtCopy'] ?? false)
3225
                        && !($tE['disablePrependAtCopy'] ?? false)
3226
                    ) {
3227
                        $value = $this->getCopyHeader($table, $this->resolvePid($table, $destPid), $field, $this->clearPrefixFromValue($table, $value), 0);
3228
                    }
3229
                    // Processing based on the TCA config field type (files, references, flexforms...)
3230
                    $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $tscPID, $language);
3231
                }
3232
                // Add value to array.
3233
                $data[$table][$theNewID][$field] = $value;
3234
            }
3235
        }
3236
        // Overriding values:
3237
        if ($GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
3238
            $data[$table][$theNewID][$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
3239
        }
3240
        // Setting original UID:
3241
        if ($GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? false) {
3242
            $data[$table][$theNewID][$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3243
        }
3244
        // Do the copy by simply submitting the array through DataHandler:
3245
        /** @var DataHandler $copyTCE */
3246
        $copyTCE = $this->getLocalTCE();
3247
        $copyTCE->start($data, [], $this->BE_USER);
3248
        $copyTCE->process_datamap();
3249
        // Getting the new UID:
3250
        $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID];
3251
        if ($theNewSQLID) {
3252
            $this->copyMappingArray[$table][$origUid] = $theNewSQLID;
3253
            // Keep automatically versionized record information:
3254
            if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) {
3255
                $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID];
3256
            }
3257
        }
3258
        $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3259
        unset($copyTCE);
3260
        if (!$ignoreLocalization && $language == 0) {
3261
            //repointing the new translation records to the parent record we just created
3262
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $theNewSQLID;
3263
            if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3264
                $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = 0;
3265
            }
3266
            $this->copyL10nOverlayRecords($table, $uid, $destPid, $first, $overrideValues, $excludeFields);
3267
        }
3268
3269
        return $theNewSQLID;
3270
    }
3271
3272
    /**
3273
     * Copying pages
3274
     * Main function for copying pages.
3275
     *
3276
     * @param int $uid Page UID to copy
3277
     * @param int $destPid Destination PID: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
3278
     * @internal should only be used from within DataHandler
3279
     */
3280
    public function copyPages($uid, $destPid)
3281
    {
3282
        // Initialize:
3283
        $uid = (int)$uid;
3284
        $destPid = (int)$destPid;
3285
3286
        $copyTablesAlongWithPage = $this->getAllowedTablesToCopyWhenCopyingAPage();
3287
        // Begin to copy pages if we're allowed to:
3288
        if ($this->admin || in_array('pages', $copyTablesAlongWithPage, true)) {
3289
            // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured)
3290
            // This method also copies the localizations of a page
3291
            $theNewRootID = $this->copySpecificPage($uid, $destPid, $copyTablesAlongWithPage, true);
3292
            // If we're going to copy recursively
3293
            if ($theNewRootID && $this->copyTree) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $theNewRootID of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
3294
                // Get ALL subpages to copy (read-permissions are respected!):
3295
                $CPtable = $this->int_pageTreeInfo([], $uid, (int)$this->copyTree, $theNewRootID);
3296
                // Now copying the subpages:
3297
                foreach ($CPtable as $thePageUid => $thePagePid) {
3298
                    $newPid = $this->copyMappingArray['pages'][$thePagePid];
3299
                    if (isset($newPid)) {
3300
                        $this->copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage);
3301
                    } else {
3302
                        $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch');
3303
                        break;
3304
                    }
3305
                }
3306
            }
3307
        } else {
3308
            $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page without permission to this table');
3309
        }
3310
    }
3311
3312
    /**
3313
     * Compile a list of tables that should be copied along when a page is about to be copied.
3314
     *
3315
     * First, get the list that the user is allowed to modify (all if admin),
3316
     * and then check against a possible limitation within "DataHandler->copyWhichTables" if not set to "*"
3317
     * to limit the list further down
3318
     *
3319
     * @return array
3320
     */
3321
    protected function getAllowedTablesToCopyWhenCopyingAPage(): array
3322
    {
3323
        // Finding list of tables to copy.
3324
        // These are the tables, the user may modify
3325
        $copyTablesArray = $this->admin ? $this->compileAdminTables() : explode(',', $this->BE_USER->groupData['tables_modify']);
3326
        // If not all tables are allowed then make a list of allowed tables.
3327
        // That is the tables that figure in both allowed tables AND the copyTable-list
3328
        if (strpos($this->copyWhichTables, '*') === false) {
3329
            $definedTablesToCopy = GeneralUtility::trimExplode(',', $this->copyWhichTables, true);
3330
            // Pages are always allowed
3331
            $definedTablesToCopy[] = 'pages';
3332
            $definedTablesToCopy = array_flip($definedTablesToCopy);
3333
            foreach ($copyTablesArray as $k => $table) {
3334
                if (!$table || !isset($definedTablesToCopy[$table])) {
3335
                    unset($copyTablesArray[$k]);
3336
                }
3337
            }
3338
        }
3339
        $copyTablesArray = array_unique($copyTablesArray);
3340
        return $copyTablesArray;
3341
    }
3342
    /**
3343
     * Copying a single page ($uid) to $destPid and all tables in the array copyTablesArray.
3344
     *
3345
     * @param int $uid Page uid
3346
     * @param int $destPid Destination PID: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
3347
     * @param array $copyTablesArray Table on pages to copy along with the page.
3348
     * @param bool $first Is a flag set, if the record copied is NOT a 'slave' to another record copied. That is, if this record was asked to be copied in the cmd-array
3349
     * @return int|null The id of the new page, if applicable.
3350
     * @internal should only be used from within DataHandler
3351
     */
3352
    public function copySpecificPage($uid, $destPid, $copyTablesArray, $first = false)
3353
    {
3354
        // Copy the page itself:
3355
        $theNewRootID = $this->copyRecord('pages', $uid, $destPid, $first);
3356
        $currentWorkspaceId = (int)$this->BE_USER->workspace;
3357
        // If a new page was created upon the copy operation we will proceed with all the tables ON that page:
3358
        if ($theNewRootID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $theNewRootID of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
3359
            foreach ($copyTablesArray as $table) {
3360
                // All records under the page is copied.
3361
                if ($table && is_array($GLOBALS['TCA'][$table]) && $table !== 'pages') {
3362
                    $fields = ['uid'];
3363
                    $languageField = null;
3364
                    $transOrigPointerField = null;
3365
                    $translationSourceField = null;
3366
                    if (BackendUtility::isTableLocalizable($table)) {
3367
                        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
3368
                        $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3369
                        $fields[] = $languageField;
3370
                        $fields[] = $transOrigPointerField;
3371
                        if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3372
                            $translationSourceField = $GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3373
                            $fields[] = $translationSourceField;
3374
                        }
3375
                    }
3376
                    $isTableWorkspaceEnabled = BackendUtility::isTableWorkspaceEnabled($table);
3377
                    if ($isTableWorkspaceEnabled) {
3378
                        $fields[] = 't3ver_oid';
3379
                        $fields[] = 't3ver_state';
3380
                        $fields[] = 't3ver_wsid';
3381
                    }
3382
                    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3383
                    $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
3384
                    $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId));
3385
                    $queryBuilder
3386
                        ->select(...$fields)
3387
                        ->from($table)
3388
                        ->where(
3389
                            $queryBuilder->expr()->eq(
3390
                                'pid',
3391
                                $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
3392
                            )
3393
                        );
3394
                    if (!empty($GLOBALS['TCA'][$table]['ctrl']['sortby'])) {
3395
                        $queryBuilder->orderBy($GLOBALS['TCA'][$table]['ctrl']['sortby'], 'DESC');
3396
                    }
3397
                    $queryBuilder->addOrderBy('uid');
3398
                    try {
3399
                        $result = $queryBuilder->execute();
3400
                        $rows = [];
3401
                        $movedLiveIds = [];
3402
                        $movedLiveRecords = [];
3403
                        while ($row = $result->fetch()) {
3404
                            if ($isTableWorkspaceEnabled && (int)$row['t3ver_state'] === VersionState::MOVE_POINTER) {
3405
                                $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid'];
3406
                            }
3407
                            $rows[(int)$row['uid']] = $row;
3408
                        }
3409
                        // Resolve placeholders of workspace versions
3410
                        if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) {
3411
                            // If a record was moved within the page, the PlainDataResolver needs the moved record
3412
                            // but not the original live version, otherwise the moved record is not considered at all.
3413
                            // For this reason, we find the live ids, where there was also a moved record in the SQL
3414
                            // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver.
3415
                            // see changeContentSortingAndCopyDraftPage test
3416
                            foreach ($movedLiveIds as $liveId => $movePlaceHolderId) {
3417
                                if (isset($rows[$liveId])) {
3418
                                    $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId];
3419
                                    unset($rows[$liveId]);
3420
                                }
3421
                            }
3422
                            $rows = array_reverse(
3423
                                $this->resolveVersionedRecords(
3424
                                    $table,
3425
                                    implode(',', $fields),
3426
                                    $GLOBALS['TCA'][$table]['ctrl']['sortby'],
3427
                                    array_keys($rows)
3428
                                ),
3429
                                true
3430
                            );
3431
                            foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) {
3432
                                $rows[$movePlaceHolderId] = $liveRecord;
3433
                            }
3434
                        }
3435
                        if (is_array($rows)) {
3436
                            $languageSourceMap = [];
3437
                            $overrideValues = $translationSourceField ? [$translationSourceField => 0] : [];
3438
                            $doRemap = false;
3439
                            foreach ($rows as $row) {
3440
                                // Skip localized records that will be processed in
3441
                                // copyL10nOverlayRecords() on copying the default language record
3442
                                $transOrigPointer = $row[$transOrigPointerField] ?? 0;
3443
                                if (!empty($languageField)
3444
                                    && $row[$languageField] > 0
3445
                                    && $transOrigPointer > 0
3446
                                    && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer]))
3447
                                ) {
3448
                                    continue;
3449
                                }
3450
                                // Copying each of the underlying records...
3451
                                $newUid = $this->copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues);
3452
                                if ($translationSourceField) {
3453
                                    $languageSourceMap[$row['uid']] = $newUid;
3454
                                    if ($row[$languageField] > 0) {
3455
                                        $doRemap = true;
3456
                                    }
3457
                                }
3458
                            }
3459
                            if ($doRemap) {
3460
                                //remap is needed for records in non-default language records in the "free mode"
3461
                                $this->copy_remapTranslationSourceField($table, $rows, $languageSourceMap);
3462
                            }
3463
                        }
3464
                    } catch (DBALException $e) {
3465
                        $databaseErrorMessage = $e->getPrevious()->getMessage();
3466
                        $this->log($table, $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: ' . $databaseErrorMessage);
3467
                    }
3468
                }
3469
            }
3470
            $this->processRemapStack();
3471
            return $theNewRootID;
3472
        }
3473
        return null;
3474
    }
3475
3476
    /**
3477
     * Copying records, but makes a "raw" copy of a record.
3478
     * Basically the only thing observed is field processing like the copying of files and correction of ids. All other fields are 1-1 copied.
3479
     * Technically the copy is made with THIS instance of the DataHandler class contrary to copyRecord() which creates a new instance and uses the processData() function.
3480
     * The copy is created by insertNewCopyVersion() which bypasses most of the regular input checking associated with processData() - maybe copyRecord() should even do this as well!?
3481
     * This function is used to create new versions of a record.
3482
     * NOTICE: DOES NOT CHECK PERMISSIONS to create! And since page permissions are just passed through and not changed to the user who executes the copy we cannot enforce permissions without getting an incomplete copy - unless we change permissions of course.
3483
     *
3484
     * @param string $table Element table
3485
     * @param int $uid Element UID
3486
     * @param int $pid Element PID (real PID, not checked)
3487
     * @param array $overrideArray Override array - must NOT contain any fields not in the table!
3488
     * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently
3489
     * @return int Returns the new ID of the record (if applicable)
3490
     * @internal should only be used from within DataHandler
3491
     */
3492
    public function copyRecord_raw($table, $uid, $pid, $overrideArray = [], array $workspaceOptions = [])
3493
    {
3494
        $uid = (int)$uid;
3495
        // Stop any actions if the record is marked to be deleted:
3496
        // (this can occur if IRRE elements are versionized and child elements are removed)
3497
        if ($this->isElementToBeDeleted($table, $uid)) {
3498
            return null;
3499
        }
3500
        // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before:
3501
        if (!$GLOBALS['TCA'][$table] || !$uid || $this->isRecordCopied($table, $uid)) {
3502
            return null;
3503
        }
3504
3505
        // Fetch record with permission check
3506
        $row = $this->recordInfoWithPermissionCheck($table, $uid, Permission::PAGE_SHOW);
3507
3508
        // This checks if the record can be selected which is all that a copy action requires.
3509
        if ($row === false) {
3510
            $this->log(
3511
                $table,
3512
                $uid,
3513
                SystemLogDatabaseAction::DELETE,
3514
                0,
3515
                SystemLogErrorClassification::USER_ERROR,
3516
                'Attempt to rawcopy/versionize record which either does not exist or you don\'t have permission to read'
3517
            );
3518
            return null;
3519
        }
3520
3521
        // Set up fields which should not be processed. They are still written - just passed through no-questions-asked!
3522
        $nonFields = ['uid', 'pid', 't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody'];
3523
3524
        // Merge in override array.
3525
        $row = array_merge($row, $overrideArray);
3526
        // Traverse ALL fields of the selected record:
3527
        foreach ($row as $field => $value) {
3528
            /** @var string $field */
3529
            if (!in_array($field, $nonFields, true)) {
3530
                // Get TCA configuration for the field:
3531
                $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? false;
3532
                if (is_array($conf)) {
3533
                    // Processing based on the TCA config field type (files, references, flexforms...)
3534
                    $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions);
3535
                }
3536
                // Add value to array.
3537
                $row[$field] = $value;
3538
            }
3539
        }
3540
        $row['pid'] = $pid;
3541
        // Setting original UID:
3542
        if ($GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? '') {
3543
            $row[$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3544
        }
3545
        // Do the copy by internal function
3546
        $theNewSQLID = $this->insertNewCopyVersion($table, $row, $pid);
3547
3548
        // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records
3549
        // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference
3550
        // for a eg. tt_content record is marked as deleted. The tt_content record then needs a reference index update.
3551
        // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now.
3552
        if (!empty($workspaceOptions)) {
3553
            $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$row['uid'], (int)$this->BE_USER->workspace);
3554
        }
3555
3556
        if ($theNewSQLID) {
3557
            $this->dbAnalysisStoreExec();
3558
            $this->dbAnalysisStore = [];
3559
            return $this->copyMappingArray[$table][$uid] = $theNewSQLID;
3560
        }
3561
        return null;
3562
    }
3563
3564
    /**
3565
     * Inserts a record in the database, passing TCA configuration values through checkValue() but otherwise does NOTHING and checks nothing regarding permissions.
3566
     * Passes the "version" parameter to insertDB() so the copy will look like a new version in the log - should probably be changed or modified a bit for more broad usage...
3567
     *
3568
     * @param string $table Table name
3569
     * @param array $fieldArray Field array to insert as a record
3570
     * @param int $realPid The value of PID field.
3571
     * @return int Returns the new ID of the record (if applicable)
3572
     * @internal should only be used from within DataHandler
3573
     */
3574
    public function insertNewCopyVersion($table, $fieldArray, $realPid)
3575
    {
3576
        $id = StringUtility::getUniqueId('NEW');
3577
        // $fieldArray is set as current record.
3578
        // The point is that when new records are created as copies with flex type fields there might be a field containing information about which DataStructure to use and without that information the flexforms cannot be correctly processed.... This should be OK since the $checkValueRecord is used by the flexform evaluation only anyways...
3579
        $this->checkValue_currentRecord = $fieldArray;
3580
        // Makes sure that transformations aren't processed on the copy.
3581
        $backupDontProcessTransformations = $this->dontProcessTransformations;
3582
        $this->dontProcessTransformations = true;
3583
        // Traverse record and input-process each value:
3584
        foreach ($fieldArray as $field => $fieldValue) {
3585
            if (isset($GLOBALS['TCA'][$table]['columns'][$field])) {
3586
                // Evaluating the value.
3587
                $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
3588
                if (isset($res['value'])) {
3589
                    $fieldArray[$field] = $res['value'];
3590
                }
3591
            }
3592
        }
3593
        // System fields being set:
3594
        if ($GLOBALS['TCA'][$table]['ctrl']['crdate']) {
3595
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
3596
        }
3597
        if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id']) {
3598
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
3599
        }
3600
        if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
3601
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
3602
        }
3603
        // Finally, insert record:
3604
        $this->insertDB($table, $id, $fieldArray, true);
3605
        // Resets dontProcessTransformations to the previous state.
3606
        $this->dontProcessTransformations = $backupDontProcessTransformations;
3607
        // Return new id:
3608
        return $this->substNEWwithIDs[$id];
3609
    }
3610
3611
    /**
3612
     * Processing/Preparing content for copyRecord() function
3613
     *
3614
     * @param string $table Table name
3615
     * @param int $uid Record uid
3616
     * @param string $field Field name being processed
3617
     * @param string $value Input value to be processed.
3618
     * @param array $row Record array
3619
     * @param array $conf TCA field configuration
3620
     * @param int $realDestPid Real page id (pid) the record is copied to
3621
     * @param int $language Language ID (from sys_language table) used in the duplicated record
3622
     * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently
3623
     * @return array|string
3624
     * @internal
3625
     * @see copyRecord()
3626
     */
3627
    public function copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = [])
3628
    {
3629
        $inlineSubType = $this->getInlineFieldType($conf);
3630
        // Get the localization mode for the current (parent) record (keep|select):
3631
        // Register if there are references to take care of or MM is used on an inline field (no change to value):
3632
        if ($this->isReferenceField($conf) || $inlineSubType === 'mm') {
3633
            $value = $this->copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language);
3634
        } elseif ($inlineSubType !== false) {
3635
            $value = $this->copyRecord_processInline($table, $uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions);
3636
        }
3637
        // For "flex" fieldtypes we need to traverse the structure for two reasons: If there are file references they have to be prepended with absolute paths and if there are database reference they MIGHT need to be remapped (still done in remapListedDBRecords())
3638
        if (isset($conf['type']) && $conf['type'] === 'flex') {
3639
            // Get current value array:
3640
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
3641
            $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
3642
                ['config' => $conf],
3643
                $table,
3644
                $field,
3645
                $row
3646
            );
3647
            $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
3648
            $currentValueArray = GeneralUtility::xml2array($value);
3649
            // Traversing the XML structure, processing files:
3650
            if (is_array($currentValueArray)) {
3651
                $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $uid, $field, $realDestPid], 'copyRecord_flexFormCallBack', $workspaceOptions);
3652
                // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created.
3653
                $value = $currentValueArray;
3654
            }
3655
        }
3656
        return $value;
3657
    }
3658
3659
    /**
3660
     * Processes the children of an MM relation field (select, group, inline) when the parent record is copied.
3661
     *
3662
     * @param string $table
3663
     * @param int $uid
3664
     * @param string $field
3665
     * @param string $value
3666
     * @param array $conf
3667
     * @param int $language
3668
     * @return string
3669
     */
3670
    protected function copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language)
3671
    {
3672
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
3673
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
3674
        $mmTable = isset($conf['MM']) && $conf['MM'] ? $conf['MM'] : '';
3675
        $localizeForeignTable = isset($conf['foreign_table']) && BackendUtility::isTableLocalizable($conf['foreign_table']);
3676
        // Localize referenced records of select fields:
3677
        $localizingNonManyToManyFieldReferences = empty($mmTable) && $localizeForeignTable && isset($conf['localizeReferencesAtParentLocalization']) && $conf['localizeReferencesAtParentLocalization'];
3678
        /** @var RelationHandler $dbAnalysis */
3679
        $dbAnalysis = $this->createRelationHandlerInstance();
3680
        $dbAnalysis->start($value, $allowedTables, $mmTable, $uid, $table, $conf);
3681
        $purgeItems = false;
3682
        if ($language > 0 && $localizingNonManyToManyFieldReferences) {
3683
            foreach ($dbAnalysis->itemArray as $index => $item) {
3684
                // Since select fields can reference many records, check whether there's already a localization:
3685
                $recordLocalization = BackendUtility::getRecordLocalization($item['table'], $item['id'], $language);
3686
                if ($recordLocalization) {
3687
                    $dbAnalysis->itemArray[$index]['id'] = $recordLocalization[0]['uid'];
3688
                } elseif ($this->isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . (string)$language) === false) {
3689
                    $dbAnalysis->itemArray[$index]['id'] = $this->localize($item['table'], $item['id'], $language);
3690
                }
3691
            }
3692
            $purgeItems = true;
3693
        }
3694
3695
        if ($purgeItems || $mmTable) {
3696
            $dbAnalysis->purgeItemArray();
3697
            $value = implode(',', $dbAnalysis->getValueArray($prependName));
0 ignored issues
show
Bug introduced by
It seems like $prependName can also be of type string; however, parameter $prependTableName of TYPO3\CMS\Core\Database\...andler::getValueArray() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

3697
            $value = implode(',', $dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prependName));
Loading history...
3698
        }
3699
        // Setting the value in this array will notify the remapListedDBRecords() function that this field MAY need references to be corrected
3700
        if ($value) {
3701
            $this->registerDBList[$table][$uid][$field] = $value;
3702
        }
3703
3704
        return $value;
3705
    }
3706
3707
    /**
3708
     * Processes child records in an inline (IRRE) element when the parent record is copied.
3709
     *
3710
     * @param string $table
3711
     * @param int $uid
3712
     * @param string $field
3713
     * @param string $value
3714
     * @param array $row
3715
     * @param array $conf
3716
     * @param int $realDestPid
3717
     * @param int $language
3718
     * @param array $workspaceOptions
3719
     * @return string
3720
     */
3721
    protected function copyRecord_processInline(
3722
        $table,
3723
        $uid,
3724
        $field,
3725
        $value,
3726
        $row,
3727
        $conf,
3728
        $realDestPid,
3729
        $language,
3730
        array $workspaceOptions
3731
    ) {
3732
        // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
3733
        /** @var RelationHandler $dbAnalysis */
3734
        $dbAnalysis = $this->createRelationHandlerInstance();
3735
        $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
3736
        // Walk through the items, copy them and remember the new id:
3737
        foreach ($dbAnalysis->itemArray as $k => $v) {
3738
            $newId = null;
3739
            // If language is set and differs from original record, this isn't a copy action but a localization of our parent/ancestor:
3740
            if ($language > 0 && BackendUtility::isTableLocalizable($table) && $language != $row[$GLOBALS['TCA'][$table]['ctrl']['languageField']]) {
3741
                // Children should be localized when the parent gets localized the first time, just do it:
3742
                $newId = $this->localize($v['table'], $v['id'], $language);
3743
            } else {
3744
                if (!MathUtility::canBeInterpretedAsInteger($realDestPid)) {
3745
                    $newId = $this->copyRecord($v['table'], $v['id'], -(int)($v['id']));
3746
                // If the destination page id is a NEW string, keep it on the same page
3747
                } elseif ($this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($v['table'])) {
3748
                    // A filled $workspaceOptions indicated that this call
3749
                    // has it's origin in previous versionizeRecord() processing
3750
                    if (!empty($workspaceOptions)) {
3751
                        // Versions use live default id, thus the "new"
3752
                        // id is the original live default child record
3753
                        $newId = $v['id'];
3754
                        $this->versionizeRecord(
3755
                            $v['table'],
3756
                            $v['id'],
3757
                            $workspaceOptions['label'] ?? 'Auto-created for WS #' . $this->BE_USER->workspace,
3758
                            $workspaceOptions['delete'] ?? false
3759
                        );
3760
                    // Otherwise just use plain copyRecord() to create placeholders etc.
3761
                    } else {
3762
                        // If a record has been copied already during this request,
3763
                        // prevent superfluous duplication and use the existing copy
3764
                        if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
3765
                            $newId = $this->copyMappingArray[$v['table']][$v['id']];
3766
                        } else {
3767
                            $newId = $this->copyRecord($v['table'], $v['id'], $realDestPid);
3768
                        }
3769
                    }
3770
                } elseif ($this->BE_USER->workspace > 0 && !BackendUtility::isTableWorkspaceEnabled($v['table'])) {
3771
                    // We are in workspace context creating a new parent version and have a child table
3772
                    // that is not workspace aware. We don't do anything with this child.
3773
                    continue;
3774
                } else {
3775
                    // If a record has been copied already during this request,
3776
                    // prevent superfluous duplication and use the existing copy
3777
                    if (isset($this->copyMappingArray[$v['table']][$v['id']])) {
3778
                        $newId = $this->copyMappingArray[$v['table']][$v['id']];
3779
                    } else {
3780
                        $newId = $this->copyRecord_raw($v['table'], $v['id'], $realDestPid, [], $workspaceOptions);
3781
                    }
3782
                }
3783
            }
3784
            // If the current field is set on a page record, update the pid of related child records:
3785
            if ($table === 'pages') {
3786
                $this->registerDBPids[$v['table']][$v['id']] = $uid;
3787
            } elseif (isset($this->registerDBPids[$table][$uid])) {
3788
                $this->registerDBPids[$v['table']][$v['id']] = $this->registerDBPids[$table][$uid];
3789
            }
3790
            $dbAnalysis->itemArray[$k]['id'] = $newId;
3791
        }
3792
        // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
3793
        $value = implode(',', $dbAnalysis->getValueArray());
3794
        $this->registerDBList[$table][$uid][$field] = $value;
3795
3796
        return $value;
3797
    }
3798
3799
    /**
3800
     * Callback function for traversing the FlexForm structure in relation to creating copied files of file relations inside of flex form structures.
3801
     *
3802
     * @param array $pParams Array of parameters in num-indexes: table, uid, field
3803
     * @param array $dsConf TCA field configuration (from Data Structure XML)
3804
     * @param string $dataValue The value of the flexForm field
3805
     * @param string $_1 Not used.
3806
     * @param string $_2 Not used.
3807
     * @param string $_3 Not used.
3808
     * @param array $workspaceOptions
3809
     * @return array Result array with key "value" containing the value of the processing.
3810
     * @see copyRecord()
3811
     * @see checkValue_flex_procInData_travDS()
3812
     * @internal should only be used from within DataHandler
3813
     */
3814
    public function copyRecord_flexFormCallBack($pParams, $dsConf, $dataValue, $_1, $_2, $_3, $workspaceOptions)
3815
    {
3816
        // Extract parameters:
3817
        [$table, $uid, $field, $realDestPid] = $pParams;
3818
        // If references are set for this field, set flag so they can be corrected later (in ->remapListedDBRecords())
3819
        if (($this->isReferenceField($dsConf) || $this->getInlineFieldType($dsConf) !== false) && (string)$dataValue !== '') {
3820
            $dataValue = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $dataValue, [], $dsConf, $realDestPid, 0, $workspaceOptions);
3821
            $this->registerDBList[$table][$uid][$field] = 'FlexForm_reference';
3822
        }
3823
        // Return
3824
        return ['value' => $dataValue];
3825
    }
3826
3827
    /**
3828
     * Find l10n-overlay records and perform the requested copy action for these records.
3829
     *
3830
     * @param string $table Record Table
3831
     * @param int $uid UID of the record in the default language
3832
     * @param int $destPid Position to copy to
3833
     * @param bool $first
3834
     * @param array $overrideValues
3835
     * @param string $excludeFields
3836
     * @internal should only be used from within DataHandler
3837
     */
3838
    public function copyL10nOverlayRecords($table, $uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '')
3839
    {
3840
        // There's no need to perform this for tables that are not localizable
3841
        if (!BackendUtility::isTableLocalizable($table)) {
3842
            return;
3843
        }
3844
3845
        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
3846
        $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
3847
3848
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3849
        $queryBuilder->getRestrictions()
3850
            ->removeAll()
3851
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
3852
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
3853
3854
        $queryBuilder->select('*')
3855
            ->from($table)
3856
            ->where(
3857
                $queryBuilder->expr()->eq(
3858
                    $transOrigPointerField,
3859
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT, ':pointer')
3860
                )
3861
            );
3862
3863
        // Never copy the actual placeholders around, as the newly copied records are
3864
        // always created as new record / new placeholder pairs
3865
        if (BackendUtility::isTableWorkspaceEnabled($table)) {
3866
            $queryBuilder->andWhere(
3867
                $queryBuilder->expr()->neq(
3868
                    't3ver_state',
3869
                    VersionState::DELETE_PLACEHOLDER
3870
                )
3871
            );
3872
        }
3873
3874
        // If $destPid is < 0, get the pid of the record with uid equal to abs($destPid)
3875
        $tscPID = BackendUtility::getTSconfig_pidValue($table, $uid, $destPid) ?? 0;
3876
        // Get the localized records to be copied
3877
        $l10nRecords = $queryBuilder->execute()->fetchAll();
3878
        if (is_array($l10nRecords)) {
3879
            $localizedDestPids = [];
3880
            // If $destPid < 0, then it is the uid of the original language record we are inserting after
3881
            if ($destPid < 0) {
3882
                // Get the localized records of the record we are inserting after
3883
                $queryBuilder->setParameter('pointer', abs($destPid), \PDO::PARAM_INT);
3884
                $destL10nRecords = $queryBuilder->execute()->fetchAll();
3885
                // Index the localized record uids by language
3886
                if (is_array($destL10nRecords)) {
3887
                    foreach ($destL10nRecords as $record) {
3888
                        $localizedDestPids[$record[$languageField]] = -$record['uid'];
3889
                    }
3890
                }
3891
            }
3892
            $languageSourceMap = [
3893
                $uid => $overrideValues[$transOrigPointerField]
3894
            ];
3895
            // Copy the localized records after the corresponding localizations of the destination record
3896
            foreach ($l10nRecords as $record) {
3897
                $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0);
3898
                if ($localizedDestPid < 0) {
3899
                    $newUid = $this->copyRecord($table, $record['uid'], $localizedDestPid, $first, $overrideValues, $excludeFields, $record[$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
3900
                } else {
3901
                    $newUid = $this->copyRecord($table, $record['uid'], $destPid < 0 ? $tscPID : $destPid, $first, $overrideValues, $excludeFields, $record[$GLOBALS['TCA'][$table]['ctrl']['languageField']]);
3902
                }
3903
                $languageSourceMap[$record['uid']] = $newUid;
3904
            }
3905
            $this->copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap);
3906
        }
3907
    }
3908
3909
    /**
3910
     * Remap languageSource field to uids of newly created records
3911
     *
3912
     * @param string $table Table name
3913
     * @param array $l10nRecords array of localized records from the page we're copying from (source records)
3914
     * @param array $languageSourceMap array mapping source records uids to newly copied uids
3915
     */
3916
    protected function copy_remapTranslationSourceField($table, $l10nRecords, $languageSourceMap)
3917
    {
3918
        if (empty($GLOBALS['TCA'][$table]['ctrl']['translationSource']) || empty($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
3919
            return;
3920
        }
3921
        $translationSourceFieldName = $GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3922
        $translationParentFieldName = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3923
3924
        //We can avoid running these update queries by sorting the $l10nRecords by languageSource dependency (in copyL10nOverlayRecords)
3925
        //and first copy records depending on default record (and map the field).
3926
        foreach ($l10nRecords as $record) {
3927
            $oldSourceUid = $record[$translationSourceFieldName];
3928
            if ($oldSourceUid <= 0 && $record[$translationParentFieldName] > 0) {
3929
                //BC fix - in connected mode 'translationSource' field should not be 0
3930
                $oldSourceUid = $record[$translationParentFieldName];
3931
            }
3932
            if ($oldSourceUid > 0) {
3933
                if (empty($languageSourceMap[$oldSourceUid])) {
3934
                    // we don't have mapping information available e.g when copyRecord returned null
3935
                    continue;
3936
                }
3937
                $newFieldValue = $languageSourceMap[$oldSourceUid];
3938
                $updateFields = [
3939
                    $translationSourceFieldName => $newFieldValue
3940
                ];
3941
                GeneralUtility::makeInstance(ConnectionPool::class)
3942
                    ->getConnectionForTable($table)
3943
                    ->update($table, $updateFields, ['uid' => (int)$languageSourceMap[$record['uid']]]);
3944
                if ($this->BE_USER->workspace > 0) {
3945
                    GeneralUtility::makeInstance(ConnectionPool::class)
3946
                        ->getConnectionForTable($table)
3947
                        ->update($table, $updateFields, ['t3ver_oid' => (int)$languageSourceMap[$record['uid']], 't3ver_wsid' => $this->BE_USER->workspace]);
3948
                }
3949
            }
3950
        }
3951
    }
3952
3953
    /*********************************************
3954
     *
3955
     * Cmd: Moving, Localizing
3956
     *
3957
     ********************************************/
3958
    /**
3959
     * Moving single records
3960
     *
3961
     * @param string $table Table name to move
3962
     * @param int $uid Record uid to move
3963
     * @param int $destPid Position to move to: $destPid: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
3964
     * @internal should only be used from within DataHandler
3965
     */
3966
    public function moveRecord($table, $uid, $destPid)
3967
    {
3968
        if (!$GLOBALS['TCA'][$table]) {
3969
            return;
3970
        }
3971
3972
        // In case the record to be moved turns out to be an offline version,
3973
        // we have to find the live version and work on that one.
3974
        if ($lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, $uid, 'uid')) {
3975
            $uid = $lookForLiveVersion['uid'];
3976
        }
3977
        // Initialize:
3978
        $destPid = (int)$destPid;
3979
        // Get this before we change the pid (for logging)
3980
        $propArr = $this->getRecordProperties($table, $uid);
3981
        $moveRec = $this->getRecordProperties($table, $uid, true);
3982
        // This is the actual pid of the moving to destination
3983
        $resolvedPid = $this->resolvePid($table, $destPid);
3984
        // Finding out, if the record may be moved from where it is. If the record is a non-page, then it depends on edit-permissions.
3985
        // If the record is a page, then there are two options: If the page is moved within itself,
3986
        // (same pid) it's edit-perms of the pid. If moved to another place then its both delete-perms of the pid and new-page perms on the destination.
3987
        if ($table !== 'pages' || $resolvedPid == $moveRec['pid']) {
3988
            // Edit rights for the record...
3989
            $mayMoveAccess = $this->checkRecordUpdateAccess($table, $uid);
3990
        } else {
3991
            $mayMoveAccess = $this->doesRecordExist($table, $uid, Permission::PAGE_DELETE);
3992
        }
3993
        // Finding out, if the record may be moved TO another place. Here we check insert-rights (non-pages = edit, pages = new),
3994
        // unless the pages are moved on the same pid, then edit-rights are checked
3995
        if ($table !== 'pages' || $resolvedPid != $moveRec['pid']) {
3996
            // Insert rights for the record...
3997
            $mayInsertAccess = $this->checkRecordInsertAccess($table, $resolvedPid, SystemLogDatabaseAction::MOVE);
3998
        } else {
3999
            $mayInsertAccess = $this->checkRecordUpdateAccess($table, $uid);
4000
        }
4001
        // Checking if there is anything else disallowing moving the record by checking if editing is allowed
4002
        $fullLanguageCheckNeeded = $table !== 'pages';
4003
        $mayEditAccess = $this->BE_USER->recordEditAccessInternals($table, $uid, false, false, $fullLanguageCheckNeeded);
4004
        // If moving is allowed, begin the processing:
4005
        if (!$mayEditAccess) {
4006
            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record "%s" (%s) without having permissions to do so. [' . $this->BE_USER->errorMsg . ']', 14, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4007
            return;
4008
        }
4009
4010
        if (!$mayMoveAccess) {
4011
            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) without having permissions to do so.', 14, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4012
            return;
4013
        }
4014
4015
        if (!$mayInsertAccess) {
4016
            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) without having permissions to insert.', 14, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4017
            return;
4018
        }
4019
4020
        $recordWasMoved = false;
4021
        // Move the record via a hook, used e.g. for versioning
4022
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4023
            $hookObj = GeneralUtility::makeInstance($className);
4024
            if (method_exists($hookObj, 'moveRecord')) {
4025
                $hookObj->moveRecord($table, $uid, $destPid, $propArr, $moveRec, $resolvedPid, $recordWasMoved, $this);
4026
            }
4027
        }
4028
        // Move the record if a hook hasn't moved it yet
4029
        if (!$recordWasMoved) {
0 ignored issues
show
introduced by
The condition $recordWasMoved is always false.
Loading history...
4030
            $this->moveRecord_raw($table, $uid, $destPid);
4031
        }
4032
    }
4033
4034
    /**
4035
     * Moves a record without checking security of any sort.
4036
     * USE ONLY INTERNALLY
4037
     *
4038
     * @param string $table Table name to move
4039
     * @param int $uid Record uid to move
4040
     * @param int $destPid Position to move to: $destPid: >=0 then it points to a page-id on which to insert the record (as the first element). <0 then it points to a uid from its own table after which to insert it (works if
4041
     * @see moveRecord()
4042
     * @internal should only be used from within DataHandler
4043
     */
4044
    public function moveRecord_raw($table, $uid, $destPid)
4045
    {
4046
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
4047
        $origDestPid = $destPid;
4048
        // This is the actual pid of the moving to destination
4049
        $resolvedPid = $this->resolvePid($table, $destPid);
4050
        // Checking if the pid is negative, but no sorting row is defined. In that case, find the correct pid.
4051
        // Basically this check make the error message 4-13 meaning less... But you can always remove this check if you
4052
        // prefer the error instead of a no-good action (which is to move the record to its own page...)
4053
        if (($destPid < 0 && !$sortColumn) || $destPid >= 0) {
4054
            $destPid = $resolvedPid;
4055
        }
4056
        // Get this before we change the pid (for logging)
4057
        $propArr = $this->getRecordProperties($table, $uid);
4058
        $moveRec = $this->getRecordProperties($table, $uid, true);
4059
        // Prepare user defined objects (if any) for hooks which extend this function:
4060
        $hookObjectsArr = [];
4061
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['moveRecordClass'] ?? [] as $className) {
4062
            $hookObjectsArr[] = GeneralUtility::makeInstance($className);
4063
        }
4064
        // Timestamp field:
4065
        $updateFields = [];
4066
        if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
4067
            $updateFields[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
4068
        }
4069
4070
        // Check if this is a translation of a page, if so then it just needs to be kept "sorting" in sync
4071
        // Usually called from moveL10nOverlayRecords()
4072
        if ($table === 'pages') {
4073
            $defaultLanguagePageUid = $this->getDefaultLanguagePageId((int)$uid);
4074
            // In workspaces, the default language page may have been moved to a different pid than the
4075
            // default language page record of live workspace. In this case, localized pages need to be
4076
            // moved to the pid of the workspace move record.
4077
            $defaultLanguagePageWorkspaceOverlay = BackendUtility::getWorkspaceVersionOfRecord((int)$this->BE_USER->workspace, 'pages', $defaultLanguagePageUid, 'uid');
4078
            if (is_array($defaultLanguagePageWorkspaceOverlay)) {
4079
                $defaultLanguagePageUid = (int)$defaultLanguagePageWorkspaceOverlay['uid'];
4080
            }
4081
            if ($defaultLanguagePageUid !== (int)$uid) {
4082
                // If the default language page has been moved, localized pages need to be moved to
4083
                // that pid and sorting, too.
4084
                $originalTranslationRecord = $this->recordInfo($table, $defaultLanguagePageUid, 'pid,' . $sortColumn);
4085
                $updateFields[$sortColumn] = $originalTranslationRecord[$sortColumn];
4086
                $destPid = $originalTranslationRecord['pid'];
4087
            }
4088
        }
4089
4090
        // Insert as first element on page (where uid = $destPid)
4091
        if ($destPid >= 0) {
4092
            if ($table !== 'pages' || $this->destNotInsideSelf($destPid, $uid)) {
4093
                // Clear cache before moving
4094
                [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
4095
                $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid);
4096
                // Setting PID
4097
                $updateFields['pid'] = $destPid;
4098
                // Table is sorted by 'sortby'
4099
                if ($sortColumn && !isset($updateFields[$sortColumn])) {
4100
                    $sortNumber = $this->getSortNumber($table, $uid, $destPid);
4101
                    $updateFields[$sortColumn] = $sortNumber;
4102
                }
4103
                // Check for child records that have also to be moved
4104
                $this->moveRecord_procFields($table, $uid, $destPid);
4105
                // Create query for update:
4106
                GeneralUtility::makeInstance(ConnectionPool::class)
4107
                    ->getConnectionForTable($table)
4108
                    ->update($table, $updateFields, ['uid' => (int)$uid]);
4109
                // Check for the localizations of that element
4110
                $this->moveL10nOverlayRecords($table, $uid, $destPid, $destPid);
4111
                // Call post processing hooks:
4112
                foreach ($hookObjectsArr as $hookObj) {
4113
                    if (method_exists($hookObj, 'moveRecord_firstElementPostProcess')) {
4114
                        $hookObj->moveRecord_firstElementPostProcess($table, $uid, $destPid, $moveRec, $updateFields, $this);
4115
                    }
4116
                }
4117
4118
                $this->getRecordHistoryStore()->moveRecord($table, $uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4119
                if ($this->enableLogging) {
4120
                    // Logging...
4121
                    $oldpagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4122
                    if ($destPid != $propArr['pid']) {
4123
                        // Logged to old page
4124
                        $newPropArr = $this->getRecordProperties($table, $uid);
4125
                        $newpagePropArr = $this->getRecordProperties('pages', $destPid);
4126
                        $this->log($table, $uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) to page \'%s\' (%s)', 2, [$propArr['header'], $table . ':' . $uid, $newpagePropArr['header'], $newPropArr['pid']], $propArr['pid']);
4127
                        // Logged to new page
4128
                        $this->log($table, $uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) from page \'%s\' (%s)', 3, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4129
                    } else {
4130
                        // Logged to new page
4131
                        $this->log($table, $uid, SystemLogDatabaseAction::MOVE, $destPid, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) on page \'%s\' (%s)', 4, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4132
                    }
4133
                }
4134
                // Clear cache after moving
4135
                $this->registerRecordIdForPageCacheClearing($table, $uid);
4136
                $this->fixUniqueInPid($table, $uid);
4137
                $this->fixUniqueInSite($table, (int)$uid);
4138
                if ($table === 'pages') {
4139
                    $this->fixUniqueInSiteForSubpages((int)$uid);
4140
                }
4141
            } elseif ($this->enableLogging) {
4142
                $destPropArr = $this->getRecordProperties('pages', $destPid);
4143
                $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page \'%s\' (%s) to inside of its own rootline (at page \'%s\' (%s))', 10, [$propArr['header'], $uid, $destPropArr['header'], $destPid], $propArr['pid']);
4144
            }
4145
        } elseif ($sortColumn) {
4146
            // Put after another record
4147
            // Table is being sorted
4148
            // Save the position to which the original record is requested to be moved
4149
            $originalRecordDestinationPid = $destPid;
4150
            $sortInfo = $this->getSortNumber($table, $uid, $destPid);
4151
            // Setting the destPid to the new pid of the record.
4152
            $destPid = $sortInfo['pid'];
4153
            // If not an array, there was an error (which is already logged)
4154
            if (is_array($sortInfo)) {
4155
                if ($table !== 'pages' || $this->destNotInsideSelf($destPid, $uid)) {
4156
                    // clear cache before moving
4157
                    $this->registerRecordIdForPageCacheClearing($table, $uid);
4158
                    // We now update the pid and sortnumber (if not set for page translations)
4159
                    $updateFields['pid'] = $destPid;
4160
                    if (!isset($updateFields[$sortColumn])) {
4161
                        $updateFields[$sortColumn] = $sortInfo['sortNumber'];
4162
                    }
4163
                    // Check for child records that have also to be moved
4164
                    $this->moveRecord_procFields($table, $uid, $destPid);
4165
                    // Create query for update:
4166
                    GeneralUtility::makeInstance(ConnectionPool::class)
4167
                        ->getConnectionForTable($table)
4168
                        ->update($table, $updateFields, ['uid' => (int)$uid]);
4169
                    // Check for the localizations of that element
4170
                    $this->moveL10nOverlayRecords($table, $uid, $destPid, $originalRecordDestinationPid);
4171
                    // Call post processing hooks:
4172
                    foreach ($hookObjectsArr as $hookObj) {
4173
                        if (method_exists($hookObj, 'moveRecord_afterAnotherElementPostProcess')) {
4174
                            $hookObj->moveRecord_afterAnotherElementPostProcess($table, $uid, $destPid, $origDestPid, $moveRec, $updateFields, $this);
4175
                        }
4176
                    }
4177
                    $this->getRecordHistoryStore()->moveRecord($table, $uid, ['oldPageId' => $propArr['pid'], 'newPageId' => $destPid, 'oldData' => $propArr, 'newData' => $updateFields], $this->correlationId);
4178
                    if ($this->enableLogging) {
4179
                        // Logging...
4180
                        $oldpagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4181
                        if ($destPid != $propArr['pid']) {
4182
                            // Logged to old page
4183
                            $newPropArr = $this->getRecordProperties($table, $uid);
4184
                            $newpagePropArr = $this->getRecordProperties('pages', $destPid);
4185
                            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) to page \'%s\' (%s)', 2, [$propArr['header'], $table . ':' . $uid, $newpagePropArr['header'], $newPropArr['pid']], $propArr['pid']);
4186
                            // Logged to old page
4187
                            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) from page \'%s\' (%s)', 3, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4188
                        } else {
4189
                            // Logged to old page
4190
                            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::MESSAGE, 'Moved record \'%s\' (%s) on page \'%s\' (%s)', 4, [$propArr['header'], $table . ':' . $uid, $oldpagePropArr['header'], $propArr['pid']], $destPid);
4191
                        }
4192
                    }
4193
                    // Clear cache after moving
4194
                    $this->registerRecordIdForPageCacheClearing($table, $uid);
4195
                    $this->fixUniqueInPid($table, $uid);
4196
                    $this->fixUniqueInSite($table, (int)$uid);
4197
                    if ($table === 'pages') {
4198
                        $this->fixUniqueInSiteForSubpages((int)$uid);
4199
                    }
4200
                } elseif ($this->enableLogging) {
4201
                    $destPropArr = $this->getRecordProperties('pages', $destPid);
4202
                    $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move page \'%s\' (%s) to inside of its own rootline (at page \'%s\' (%s))', 10, [$propArr['header'], $uid, $destPropArr['header'], $destPid], $propArr['pid']);
4203
                }
4204
            } else {
4205
                $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) to after another record, although the table has no sorting row.', 13, [$propArr['header'], $table . ':' . $uid], $propArr['event_pid']);
4206
            }
4207
        }
4208
    }
4209
4210
    /**
4211
     * Walk through all fields of the moved record and look for children of e.g. the inline type.
4212
     * If child records are found, they are also move to the new $destPid.
4213
     *
4214
     * @param string $table Record Table
4215
     * @param int $uid Record UID
4216
     * @param int $destPid Position to move to
4217
     * @internal should only be used from within DataHandler
4218
     */
4219
    public function moveRecord_procFields($table, $uid, $destPid)
4220
    {
4221
        $row = BackendUtility::getRecordWSOL($table, $uid);
4222
        if (is_array($row) && (int)$destPid !== (int)$row['pid']) {
4223
            $conf = $GLOBALS['TCA'][$table]['columns'];
4224
            foreach ($row as $field => $value) {
4225
                $this->moveRecord_procBasedOnFieldType($table, $uid, $destPid, $value, $conf[$field]['config'] ?? []);
4226
            }
4227
        }
4228
    }
4229
4230
    /**
4231
     * Move child records depending on the field type of the parent record.
4232
     *
4233
     * @param string $table Record Table
4234
     * @param int $uid Record UID
4235
     * @param int $destPid Position to move to
4236
     * @param string $value Record field value
4237
     * @param array $conf TCA configuration of current field
4238
     * @internal should only be used from within DataHandler
4239
     */
4240
    public function moveRecord_procBasedOnFieldType($table, $uid, $destPid, $value, $conf)
4241
    {
4242
        $dbAnalysis = null;
4243
        if (!empty($conf['type']) && $conf['type'] === 'inline') {
4244
            $foreign_table = $conf['foreign_table'];
4245
            $moveChildrenWithParent = !isset($conf['behaviour']['disableMovingChildrenWithParent']) || !$conf['behaviour']['disableMovingChildrenWithParent'];
4246
            if ($foreign_table && $moveChildrenWithParent) {
4247
                $inlineType = $this->getInlineFieldType($conf);
4248
                if ($inlineType === 'list' || $inlineType === 'field') {
4249
                    if ($table === 'pages') {
4250
                        // If the inline elements are related to a page record,
4251
                        // make sure they reside at that page and not at its parent
4252
                        $destPid = $uid;
4253
                    }
4254
                    $dbAnalysis = $this->createRelationHandlerInstance();
4255
                    $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
4256
                }
4257
            }
4258
        }
4259
        // Move the records
4260
        if (isset($dbAnalysis)) {
4261
            // Moving records to a positive destination will insert each
4262
            // record at the beginning, thus the order is reversed here:
4263
            foreach (array_reverse($dbAnalysis->itemArray) as $v) {
4264
                $this->moveRecord($v['table'], $v['id'], $destPid);
4265
            }
4266
        }
4267
    }
4268
4269
    /**
4270
     * Find l10n-overlay records and perform the requested move action for these records.
4271
     *
4272
     * @param string $table Record Table
4273
     * @param int $uid Record UID
4274
     * @param int $destPid Position to move to
4275
     * @param string $originalRecordDestinationPid Position to move the original record to
4276
     * @internal should only be used from within DataHandler
4277
     */
4278
    public function moveL10nOverlayRecords($table, $uid, $destPid, $originalRecordDestinationPid)
4279
    {
4280
        // There's no need to perform this for non-localizable tables
4281
        if (!BackendUtility::isTableLocalizable($table)) {
4282
            return;
4283
        }
4284
4285
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4286
        $queryBuilder->getRestrictions()
4287
            ->removeAll()
4288
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
4289
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->BE_USER->workspace));
4290
4291
        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
4292
        $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
4293
        $l10nRecords = $queryBuilder->select('*')
4294
            ->from($table)
4295
            ->where(
4296
                $queryBuilder->expr()->eq(
4297
                    $transOrigPointerField,
4298
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT, ':pointer')
4299
                )
4300
            )
4301
            ->execute()
4302
            ->fetchAll();
4303
4304
        if (is_array($l10nRecords)) {
4305
            $localizedDestPids = [];
4306
            // If $$originalRecordDestinationPid < 0, then it is the uid of the original language record we are inserting after
4307
            if ($originalRecordDestinationPid < 0) {
4308
                // Get the localized records of the record we are inserting after
4309
                $queryBuilder->setParameter('pointer', abs($originalRecordDestinationPid), \PDO::PARAM_INT);
0 ignored issues
show
Bug introduced by
$originalRecordDestinationPid of type string is incompatible with the type double|integer expected by parameter $num of abs(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

4309
                $queryBuilder->setParameter('pointer', abs(/** @scrutinizer ignore-type */ $originalRecordDestinationPid), \PDO::PARAM_INT);
Loading history...
4310
                $destL10nRecords = $queryBuilder->execute()->fetchAll();
4311
                // Index the localized record uids by language
4312
                if (is_array($destL10nRecords)) {
4313
                    foreach ($destL10nRecords as $record) {
4314
                        $localizedDestPids[$record[$languageField]] = -$record['uid'];
4315
                    }
4316
                }
4317
            }
4318
            // Move the localized records after the corresponding localizations of the destination record
4319
            foreach ($l10nRecords as $record) {
4320
                $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0);
4321
                if ($localizedDestPid < 0) {
4322
                    $this->moveRecord($table, $record['uid'], $localizedDestPid);
4323
                } else {
4324
                    $this->moveRecord($table, $record['uid'], $destPid);
4325
                }
4326
            }
4327
        }
4328
    }
4329
4330
    /**
4331
     * Localizes a record to another system language
4332
     *
4333
     * @param string $table Table name
4334
     * @param int $uid Record uid (to be localized)
4335
     * @param int $language Language ID (from sys_language table)
4336
     * @return int|bool The uid (int) of the new translated record or FALSE (bool) if something went wrong
4337
     * @internal should only be used from within DataHandler
4338
     */
4339
    public function localize($table, $uid, $language)
4340
    {
4341
        $newId = false;
4342
        $uid = (int)$uid;
4343
        if (!$GLOBALS['TCA'][$table] || !$uid || $this->isNestedElementCallRegistered($table, $uid, 'localize-' . (string)$language) !== false) {
4344
            return false;
4345
        }
4346
4347
        $this->registerNestedElementCall($table, $uid, 'localize-' . (string)$language);
4348
        if (!$GLOBALS['TCA'][$table]['ctrl']['languageField'] || !$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']) {
4349
            $this->newlog('Localization failed; "languageField" and "transOrigPointerField" must be defined for the table ' . $table, SystemLogErrorClassification::USER_ERROR);
4350
            return false;
4351
        }
4352
        $langRec = BackendUtility::getRecord('sys_language', (int)$language, 'uid,title');
4353
        if (!$langRec) {
4354
            $this->newlog('Sys language UID "' . $language . '" not found valid!', SystemLogErrorClassification::USER_ERROR);
4355
            return false;
4356
        }
4357
4358
        if (!$this->doesRecordExist($table, $uid, Permission::PAGE_SHOW)) {
4359
            $this->newlog('Attempt to localize record ' . $table . ':' . $uid . ' without permission.', SystemLogErrorClassification::USER_ERROR);
4360
            return false;
4361
        }
4362
4363
        // Getting workspace overlay if possible - this will localize versions in workspace if any
4364
        $row = BackendUtility::getRecordWSOL($table, $uid);
4365
        if (!is_array($row)) {
0 ignored issues
show
introduced by
The condition is_array($row) is always true.
Loading history...
4366
            $this->newlog('Attempt to localize record ' . $table . ':' . $uid . ' that did not exist!', SystemLogErrorClassification::USER_ERROR);
4367
            return false;
4368
        }
4369
4370
        // Make sure that records which are translated from another language than the default language have a correct
4371
        // localization source set themselves, before translating them to another language.
4372
        if ((int)$row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4373
            && $row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0) {
4374
            $localizationParentRecord = BackendUtility::getRecord(
4375
                $table,
4376
                $row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]
4377
            );
4378
            if ((int)$localizationParentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']] !== 0) {
4379
                $this->newlog('Localization failed; Source record ' . $table . ':' . $localizationParentRecord['uid'] . ' contained a reference to an original record that is not a default record (which is strange)!', SystemLogErrorClassification::USER_ERROR);
4380
                return false;
4381
            }
4382
        }
4383
4384
        // Default language records must never have a localization parent as they are the origin of any translation.
4385
        if ((int)$row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4386
            && (int)$row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4387
            $this->newlog('Localization failed; Source record ' . $table . ':' . $row['uid'] . ' contained a reference to an original default record but is a default record itself (which is strange)!', SystemLogErrorClassification::USER_ERROR);
4388
            return false;
4389
        }
4390
4391
        $recordLocalizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'AND pid=' . (int)$row['pid']);
4392
4393
        if (!empty($recordLocalizations)) {
4394
            $this->newlog(sprintf(
4395
                'Localization failed: there already are localizations (%s) for language %d of the "%s" record %d!',
4396
                implode(', ', array_column($recordLocalizations, 'uid')),
4397
                $language,
4398
                $table,
4399
                $uid
4400
            ), 1);
4401
            return false;
4402
        }
4403
4404
        // Initialize:
4405
        $overrideValues = [];
4406
        // Set override values:
4407
        $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['languageField']] = $langRec['uid'];
4408
        // If the translated record is a default language record, set it's uid as localization parent of the new record.
4409
        // If translating from any other language, no override is needed; we just can copy the localization parent of
4410
        // the original record (which is pointing to the correspondent default language record) to the new record.
4411
        // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case.
4412
        // For pages, there is no "copy/free mode".
4413
        if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4414
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $uid;
4415
        } elseif (!$this->useTransOrigPointerField) {
4416
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = 0;
4417
        }
4418
        if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
4419
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = $uid;
4420
        }
4421
        // Copy the type (if defined in both tables) from the original record so that translation has same type as original record
4422
        if (isset($GLOBALS['TCA'][$table]['ctrl']['type'])) {
4423
            // @todo: Possible bug here? type can be something like 'table:field', which is then null in $row, writing null to $overrideValues
4424
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['type']] = $row[$GLOBALS['TCA'][$table]['ctrl']['type']] ?? null;
4425
        }
4426
        // Set exclude Fields:
4427
        foreach ($GLOBALS['TCA'][$table]['columns'] as $fN => $fCfg) {
4428
            $translateToMsg = '';
4429
            // Check if we are just prefixing:
4430
            if (isset($fCfg['l10n_mode']) && $fCfg['l10n_mode'] === 'prefixLangTitle') {
4431
                if (($fCfg['config']['type'] === 'text' || $fCfg['config']['type'] === 'input') && (string)$row[$fN] !== '') {
4432
                    [$tscPID] = BackendUtility::getTSCpid($table, $uid, '');
4433
                    $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
4434
                    $tE = $this->getTableEntries($table, $TSConfig);
4435
                    if (!empty($TSConfig['translateToMessage']) && !($tE['disablePrependAtCopy'] ?? false)) {
4436
                        $translateToMsg = $this->getLanguageService()->sL($TSConfig['translateToMessage']);
4437
                        $translateToMsg = @sprintf($translateToMsg, $langRec['title']);
4438
                    }
4439
4440
                    foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) {
4441
                        $hookObj = GeneralUtility::makeInstance($className);
4442
                        if (method_exists($hookObj, 'processTranslateTo_copyAction')) {
4443
                            $hookObj->processTranslateTo_copyAction($row[$fN], $langRec, $this, $fN);
4444
                        }
4445
                    }
4446
                    if (!empty($translateToMsg)) {
4447
                        $overrideValues[$fN] = '[' . $translateToMsg . '] ' . $row[$fN];
4448
                    } else {
4449
                        $overrideValues[$fN] = $row[$fN];
4450
                    }
4451
                }
4452
            }
4453
        }
4454
4455
        if ($table !== 'pages') {
4456
            // Get the uid of record after which this localized record should be inserted
4457
            $previousUid = $this->getPreviousLocalizedRecordUid($table, $uid, $row['pid'], $language);
4458
            // Execute the copy:
4459
            $newId = $this->copyRecord($table, $uid, -$previousUid, true, $overrideValues, '', $language);
4460
        } else {
4461
            // Create new page which needs to contain the same pid as the original page
4462
            $overrideValues['pid'] = $row['pid'];
4463
            // Take over the hidden state of the original language state, this is done due to legacy reasons where-as
4464
            // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1"
4465
            if (!empty($GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'])) {
4466
                $hiddenFieldName = $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'];
4467
                $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? $GLOBALS['TCA'][$table]['columns'][$hiddenFieldName]['config']['default'];
4468
            }
4469
            $temporaryId = StringUtility::getUniqueId('NEW');
4470
            $copyTCE = $this->getLocalTCE();
4471
            $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER);
4472
            $copyTCE->process_datamap();
4473
            // Getting the new UID as if it had been copied:
4474
            $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId];
4475
            if ($theNewSQLID) {
4476
                $this->copyMappingArray[$table][$uid] = $theNewSQLID;
4477
                $newId = $theNewSQLID;
4478
            }
4479
        }
4480
4481
        return $newId;
4482
    }
4483
4484
    /**
4485
     * Performs localization or synchronization of child records.
4486
     * The $command argument expects an array, but supports a string for backward-compatibility.
4487
     *
4488
     * $command = array(
4489
     *   'field' => 'tx_myfieldname',
4490
     *   'language' => 2,
4491
     *   // either the key 'action' or 'ids' must be set
4492
     *   'action' => 'synchronize', // or 'localize'
4493
     *   'ids' => array(1, 2, 3, 4) // child element ids
4494
     * );
4495
     *
4496
     * @param string $table The table of the localized parent record
4497
     * @param int $id The uid of the localized parent record
4498
     * @param array|string $command Defines the command to be performed (see example above)
4499
     */
4500
    protected function inlineLocalizeSynchronize($table, $id, $command)
4501
    {
4502
        $parentRecord = BackendUtility::getRecordWSOL($table, $id);
4503
4504
        // Backward-compatibility handling
4505
        if (!is_array($command)) {
4506
            // <field>, (localize | synchronize | <uid>):
4507
            $parts = GeneralUtility::trimExplode(',', $command);
4508
            $command = [
4509
                'field' => $parts[0],
4510
                // The previous process expected $id to point to the localized record already
4511
                'language' => (int)$parentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']]
4512
            ];
4513
            if (!MathUtility::canBeInterpretedAsInteger($parts[1])) {
4514
                $command['action'] = $parts[1];
4515
            } else {
4516
                $command['ids'] = [$parts[1]];
4517
            }
4518
        }
4519
4520
        // In case the parent record is the default language record, fetch the localization
4521
        if (empty($parentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
4522
            // Fetch the live record
4523
            // @todo: this needs to be revisited, as getRecordLocalization() does a BackendWorkspaceRestriction
4524
            // based on $GLOBALS[BE_USER], which could differ from the $this->BE_USER->workspace value
4525
            $parentRecordLocalization = BackendUtility::getRecordLocalization($table, $id, $command['language'], 'AND t3ver_oid=0');
4526
            if (empty($parentRecordLocalization)) {
4527
                if ($this->enableLogging) {
4528
                    $this->log($table, $id, SystemLogGenericAction::UNDEFINED, 0, SystemLogErrorClassification::MESSAGE, 'Localization for parent record ' . $table . ':' . $id . '" cannot be fetched', -1, [], $this->eventPid($table, $id, $parentRecord['pid']));
4529
                }
4530
                return;
4531
            }
4532
            $parentRecord = $parentRecordLocalization[0];
4533
            $id = $parentRecord['uid'];
4534
            // Process overlay for current selected workspace
4535
            BackendUtility::workspaceOL($table, $parentRecord);
4536
        }
4537
4538
        $field = $command['field'];
4539
        $language = $command['language'];
4540
        $action = $command['action'];
4541
        $ids = $command['ids'] ?? [];
4542
4543
        if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !isset($GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
4544
            return;
4545
        }
4546
4547
        $config = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
4548
        $foreignTable = $config['foreign_table'];
4549
4550
        $transOrigPointer = (int)$parentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
4551
        $childTransOrigPointerField = $GLOBALS['TCA'][$foreignTable]['ctrl']['transOrigPointerField'];
4552
4553
        if (!$parentRecord || !is_array($parentRecord) || $language <= 0 || !$transOrigPointer) {
4554
            return;
4555
        }
4556
4557
        $inlineSubType = $this->getInlineFieldType($config);
4558
        if ($inlineSubType === false) {
4559
            return;
4560
        }
4561
4562
        $transOrigRecord = BackendUtility::getRecordWSOL($table, $transOrigPointer);
4563
4564
        $removeArray = [];
4565
        $mmTable = $inlineSubType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : '';
4566
        // Fetch children from original language parent:
4567
        /** @var RelationHandler $dbAnalysisOriginal */
4568
        $dbAnalysisOriginal = $this->createRelationHandlerInstance();
4569
        $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config);
4570
        $elementsOriginal = [];
4571
        foreach ($dbAnalysisOriginal->itemArray as $item) {
4572
            $elementsOriginal[$item['id']] = $item;
4573
        }
4574
        unset($dbAnalysisOriginal);
4575
        // Fetch children from current localized parent:
4576
        /** @var RelationHandler $dbAnalysisCurrent */
4577
        $dbAnalysisCurrent = $this->createRelationHandlerInstance();
4578
        $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config);
4579
        // Perform synchronization: Possibly removal of already localized records:
4580
        if ($action === 'synchronize') {
4581
            foreach ($dbAnalysisCurrent->itemArray as $index => $item) {
4582
                $childRecord = BackendUtility::getRecordWSOL($item['table'], $item['id']);
4583
                if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) {
4584
                    $childTransOrigPointer = $childRecord[$childTransOrigPointerField];
4585
                    // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it:
4586
                    if (!isset($elementsOriginal[$childTransOrigPointer])) {
4587
                        unset($dbAnalysisCurrent->itemArray[$index]);
4588
                        $removeArray[$item['table']][$item['id']]['delete'] = 1;
4589
                    }
4590
                }
4591
            }
4592
        }
4593
        // Perform synchronization/localization: Possibly add unlocalized records for original language:
4594
        if ($action === 'localize' || $action === 'synchronize') {
4595
            foreach ($elementsOriginal as $originalId => $item) {
4596
                if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4597
                    continue;
4598
                }
4599
                $item['id'] = $this->localize($item['table'], $item['id'], $language);
4600
4601
                if (is_int($item['id'])) {
4602
                    $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4603
                }
4604
                $dbAnalysisCurrent->itemArray[] = $item;
4605
            }
4606
        } elseif (!empty($ids)) {
4607
            foreach ($ids as $childId) {
4608
                if (!MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) {
4609
                    continue;
4610
                }
4611
                $item = $elementsOriginal[$childId];
4612
                if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4613
                    continue;
4614
                }
4615
                $item['id'] = $this->localize($item['table'], $item['id'], $language);
4616
                if (is_int($item['id'])) {
4617
                    $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4618
                }
4619
                $dbAnalysisCurrent->itemArray[] = $item;
4620
            }
4621
        }
4622
        // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
4623
        $value = implode(',', $dbAnalysisCurrent->getValueArray());
4624
        $this->registerDBList[$table][$id][$field] = $value;
4625
        // Remove child records (if synchronization requested it):
4626
        if (is_array($removeArray) && !empty($removeArray)) {
4627
            /** @var DataHandler $tce */
4628
            $tce = GeneralUtility::makeInstance(__CLASS__, $this->referenceIndexUpdater);
4629
            $tce->enableLogging = $this->enableLogging;
4630
            $tce->start([], $removeArray, $this->BE_USER);
4631
            $tce->process_cmdmap();
4632
            unset($tce);
4633
        }
4634
        $updateFields = [];
4635
        // Handle, reorder and store relations:
4636
        if ($inlineSubType === 'list') {
4637
            $updateFields = [$field => $value];
4638
        } elseif ($inlineSubType === 'field') {
4639
            $dbAnalysisCurrent->writeForeignField($config, $id);
4640
            $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4641
        } elseif ($inlineSubType === 'mm') {
4642
            $dbAnalysisCurrent->writeMM($config['MM'], $id);
4643
            $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4644
        }
4645
        // Update field referencing to child records of localized parent record:
4646
        if (!empty($updateFields)) {
4647
            $this->updateDB($table, $id, $updateFields);
4648
        }
4649
    }
4650
4651
    /**
4652
     * Returns true if a localization of a record exists.
4653
     *
4654
     * @param string $table
4655
     * @param int $uid
4656
     * @param int $language
4657
     * @return bool
4658
     */
4659
    protected function isRecordLocalized(string $table, int $uid, int $language): bool
4660
    {
4661
        $row = BackendUtility::getRecordWSOL($table, $uid);
4662
        $localizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'pid=' . (int)$row['pid']);
4663
        return !empty($localizations);
4664
    }
4665
4666
    /*********************************************
4667
     *
4668
     * Cmd: delete
4669
     *
4670
     ********************************************/
4671
    /**
4672
     * Delete a single record
4673
     *
4674
     * @param string $table Table name
4675
     * @param int $id Record UID
4676
     * @internal should only be used from within DataHandler
4677
     */
4678
    public function deleteAction($table, $id)
4679
    {
4680
        $recordToDelete = BackendUtility::getRecord($table, $id);
4681
4682
        if (is_array($recordToDelete) && isset($recordToDelete['t3ver_wsid']) && (int)$recordToDelete['t3ver_wsid'] !== 0) {
4683
            // When dealing with a workspace record, use discard.
4684
            $this->discard($table, null, $recordToDelete);
4685
            return;
4686
        }
4687
4688
        // Record asked to be deleted was found:
4689
        if (is_array($recordToDelete)) {
4690
            $recordWasDeleted = false;
4691
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
4692
                $hookObj = GeneralUtility::makeInstance($className);
4693
                if (method_exists($hookObj, 'processCmdmap_deleteAction')) {
4694
                    $hookObj->processCmdmap_deleteAction($table, $id, $recordToDelete, $recordWasDeleted, $this);
4695
                }
4696
            }
4697
            // Delete the record if a hook hasn't deleted it yet
4698
            if (!$recordWasDeleted) {
0 ignored issues
show
introduced by
The condition $recordWasDeleted is always false.
Loading history...
4699
                $this->deleteEl($table, $id);
4700
            }
4701
        }
4702
    }
4703
4704
    /**
4705
     * Delete element from any table
4706
     *
4707
     * @param string $table Table name
4708
     * @param int $uid Record UID
4709
     * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record
4710
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4711
     * @param bool $deleteRecordsOnPage If false and if deleting pages, records on the page will not be deleted (edge case while swapping workspaces)
4712
     * @internal should only be used from within DataHandler
4713
     */
4714
    public function deleteEl($table, $uid, $noRecordCheck = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4715
    {
4716
        if ($table === 'pages') {
4717
            $this->deletePages($uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
4718
        } else {
4719
            $this->discardWorkspaceVersionsOfRecord($table, $uid);
4720
            $this->deleteRecord($table, $uid, $noRecordCheck, $forceHardDelete);
4721
        }
4722
    }
4723
4724
    /**
4725
     * Discard workspace overlays of a live record: When a live row
4726
     * is deleted, all existing workspace overlays are discarded.
4727
     *
4728
     * @param string $table Table name
4729
     * @param int $uid Record UID
4730
     * @internal should only be used from within DataHandler
4731
     */
4732
    protected function discardWorkspaceVersionsOfRecord($table, $uid): void
4733
    {
4734
        $versions = BackendUtility::selectVersionsOfRecord($table, $uid, '*', null);
4735
        if ($versions === null) {
4736
            // Null is returned by selectVersionsOfRecord() when table is not workspace aware.
4737
            return;
4738
        }
4739
        foreach ($versions as $record) {
4740
            if ($record['_CURRENT_VERSION'] ?? false) {
4741
                // The live record is included in the result from selectVersionsOfRecord()
4742
                // and marked as '_CURRENT_VERSION'. Skip this one.
4743
                continue;
4744
            }
4745
            // BE user must be put into this workspace temporarily so stuff like refindex updating
4746
            // is properly registered for this workspace when discarding records in there.
4747
            $currentUserWorkspace = $this->BE_USER->workspace;
4748
            $this->BE_USER->workspace = (int)$record['t3ver_wsid'];
4749
            $this->discard($table, null, $record);
4750
            // Switch user back to original workspace
4751
            $this->BE_USER->workspace = $currentUserWorkspace;
4752
        }
4753
    }
4754
4755
    /**
4756
     * Deleting a record
4757
     * This function may not be used to delete pages-records unless the underlying records are already deleted
4758
     * Deletes a record regardless of versioning state (live or offline, doesn't matter, the uid decides)
4759
     * If both $noRecordCheck and $forceHardDelete are set it could even delete a "deleted"-flagged record!
4760
     *
4761
     * @param string $table Table name
4762
     * @param int $uid Record UID
4763
     * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record
4764
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4765
     * @internal should only be used from within DataHandler
4766
     */
4767
    public function deleteRecord($table, $uid, $noRecordCheck = false, $forceHardDelete = false)
4768
    {
4769
        $currentUserWorkspace = (int)$this->BE_USER->workspace;
4770
        $uid = (int)$uid;
4771
        if (!$GLOBALS['TCA'][$table] || !$uid) {
4772
            $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions. [' . $this->BE_USER->errorMsg . ']');
4773
            return;
4774
        }
4775
        // Skip processing already deleted records
4776
        if (!$forceHardDelete && $this->hasDeletedRecord($table, $uid)) {
4777
            return;
4778
        }
4779
4780
        // Checking if there is anything else disallowing deleting the record by checking if editing is allowed
4781
        $fullLanguageAccessCheck = true;
4782
        if ($table === 'pages') {
4783
            // If this is a page translation, the full language access check should not be done
4784
            $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
4785
            if ($defaultLanguagePageId !== $uid) {
4786
                $fullLanguageAccessCheck = false;
4787
            }
4788
        }
4789
        $hasEditAccess = $this->BE_USER->recordEditAccessInternals($table, $uid, false, $forceHardDelete, $fullLanguageAccessCheck);
4790
        if (!$hasEditAccess) {
4791
            $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions');
4792
            return;
4793
        }
4794
        if ($table === 'pages') {
4795
            $perms = Permission::PAGE_DELETE;
4796
        } elseif ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
4797
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
4798
            $perms = Permission::PAGE_EDIT;
4799
        } else {
4800
            $perms = Permission::CONTENT_EDIT;
4801
        }
4802
        if (!$noRecordCheck && !$this->doesRecordExist($table, $uid, $perms)) {
4803
            return;
4804
        }
4805
4806
        $recordToDelete = [];
4807
        $recordWorkspaceId = 0;
4808
        if (BackendUtility::isTableWorkspaceEnabled($table)) {
4809
            $recordToDelete = BackendUtility::getRecord($table, $uid);
4810
            $recordWorkspaceId = (int)$recordToDelete['t3ver_wsid'];
4811
        }
4812
4813
        // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache
4814
        [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
4815
        $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid);
4816
        $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'];
4817
        $databaseErrorMessage = '';
4818
        if ($recordWorkspaceId > 0) {
4819
            // If this is a workspace record, use discard
4820
            $this->BE_USER->workspace = $recordWorkspaceId;
4821
            $this->discard($table, null, $recordToDelete);
4822
            // Switch user back to original workspace
4823
            $this->BE_USER->workspace = $currentUserWorkspace;
4824
        } elseif ($deleteField && !$forceHardDelete) {
4825
            $updateFields = [
4826
                $deleteField => 1
4827
            ];
4828
            if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
4829
                $updateFields[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
4830
            }
4831
            // before deleting this record, check for child records or references
4832
            $this->deleteRecord_procFields($table, $uid);
4833
            try {
4834
                // Delete all l10n records as well
4835
                $this->deletedRecords[$table][] = (int)$uid;
4836
                $this->deleteL10nOverlayRecords($table, $uid);
4837
                GeneralUtility::makeInstance(ConnectionPool::class)
4838
                    ->getConnectionForTable($table)
4839
                    ->update($table, $updateFields, ['uid' => (int)$uid]);
4840
            } catch (DBALException $e) {
4841
                $databaseErrorMessage = $e->getPrevious()->getMessage();
4842
            }
4843
        } else {
4844
            // Delete the hard way...:
4845
            try {
4846
                $this->hardDeleteSingleRecord($table, (int)$uid);
4847
                $this->deletedRecords[$table][] = (int)$uid;
4848
                $this->deleteL10nOverlayRecords($table, $uid);
4849
            } catch (DBALException $e) {
4850
                $databaseErrorMessage = $e->getPrevious()->getMessage();
4851
            }
4852
        }
4853
        if ($this->enableLogging) {
4854
            $state = SystemLogDatabaseAction::DELETE;
4855
            if ($databaseErrorMessage === '') {
4856
                if ($forceHardDelete) {
4857
                    $message = 'Record \'%s\' (%s) was deleted unrecoverable from page \'%s\' (%s)';
4858
                } else {
4859
                    $message = 'Record \'%s\' (%s) was deleted from page \'%s\' (%s)';
4860
                }
4861
                $propArr = $this->getRecordProperties($table, $uid);
4862
                $pagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4863
4864
                $this->log($table, $uid, $state, 0, SystemLogErrorClassification::MESSAGE, $message, 0, [
4865
                    $propArr['header'],
4866
                    $table . ':' . $uid,
4867
                    $pagePropArr['header'],
4868
                    $propArr['pid']
4869
                ], $propArr['event_pid']);
4870
            } else {
4871
                $this->log($table, $uid, $state, 0, SystemLogErrorClassification::TODAYS_SPECIAL, $databaseErrorMessage);
4872
            }
4873
        }
4874
4875
        // Add history entry
4876
        $this->getRecordHistoryStore()->deleteRecord($table, $uid, $this->correlationId);
4877
4878
        // Update reference index with table/uid on left side (recuid)
4879
        $this->updateRefIndex($table, $uid);
4880
        // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
4881
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $currentUserWorkspace);
4882
    }
4883
4884
    /**
4885
     * Used to delete page because it will check for branch below pages and disallowed tables on the page as well.
4886
     *
4887
     * @param int $uid Page id
4888
     * @param bool $force If TRUE, pages are not checked for permission.
4889
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4890
     * @param bool $deleteRecordsOnPage If false, records on the page will not be deleted (edge case while swapping workspaces)
4891
     * @internal should only be used from within DataHandler
4892
     */
4893
    public function deletePages($uid, $force = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4894
    {
4895
        $uid = (int)$uid;
4896
        if ($uid === 0) {
4897
            if ($this->enableLogging) {
4898
                $this->log('pages', $uid, SystemLogGenericAction::UNDEFINED, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'Deleting all pages starting from the root-page is disabled.', -1, [], 0);
4899
            }
4900
            return;
4901
        }
4902
        // Getting list of pages to delete:
4903
        if ($force) {
4904
            // Returns the branch WITHOUT permission checks (0 secures that), so it cannot return -1
4905
            $pageIdsInBranch = $this->doesBranchExist('', $uid, 0, true);
4906
            $res = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
4907
        } else {
4908
            $res = $this->canDeletePage($uid);
4909
        }
4910
        // Perform deletion if not error:
4911
        if (is_array($res)) {
4912
            foreach ($res as $deleteId) {
4913
                $this->deleteSpecificPage($deleteId, $forceHardDelete, $deleteRecordsOnPage);
4914
            }
4915
        } else {
4916
            $this->log(
4917
                'pages',
4918
                $uid,
4919
                SystemLogGenericAction::UNDEFINED,
4920
                0,
4921
                SystemLogErrorClassification::SYSTEM_ERROR,
4922
                $res,
4923
                -1,
4924
                [$res],
4925
            );
4926
        }
4927
    }
4928
4929
    /**
4930
     * Delete a page (or set deleted field to 1) and all records on it.
4931
     *
4932
     * @param int $uid Page id
4933
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4934
     * @param bool $deleteRecordsOnPage If false, records on the page will not be deleted (edge case while swapping workspaces)
4935
     * @internal
4936
     * @see deletePages()
4937
     */
4938
    public function deleteSpecificPage($uid, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4939
    {
4940
        $uid = (int)$uid;
4941
        if (!$uid) {
4942
            // Early void return on invalid uid
4943
            return;
4944
        }
4945
        $forceHardDelete = (bool)$forceHardDelete;
4946
4947
        // Delete either a default language page or a translated page
4948
        $pageIdInDefaultLanguage = $this->getDefaultLanguagePageId($uid);
4949
        $isPageTranslation = false;
4950
        $pageLanguageId = 0;
4951
        if ($pageIdInDefaultLanguage !== $uid) {
4952
            // For translated pages, translated records in other tables (eg. tt_content) for the
4953
            // to-delete translated page have their pid field set to the uid of the default language record,
4954
            // NOT the uid of the translated page record.
4955
            // If a translated page is deleted, only translations of records in other tables of this language
4956
            // should be deleted. The code checks if the to-delete page is a translated page and
4957
            // adapts the query for other tables to use the uid of the default language page as pid together
4958
            // with the language id of the translated page.
4959
            $isPageTranslation = true;
4960
            $pageLanguageId = $this->pageInfo($uid, $GLOBALS['TCA']['pages']['ctrl']['languageField']);
4961
        }
4962
4963
        if ($deleteRecordsOnPage) {
4964
            $tableNames = $this->compileAdminTables();
4965
            foreach ($tableNames as $table) {
4966
                if ($table === 'pages' || ($isPageTranslation && !BackendUtility::isTableLocalizable($table))) {
4967
                    // Skip pages table. And skip table if not translatable, but a translated page is deleted
4968
                    continue;
4969
                }
4970
4971
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4972
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
4973
                $queryBuilder
4974
                    ->select('uid')
4975
                    ->from($table)
4976
                    // order by uid is needed here to process possible live records first - overlays always
4977
                    // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order,
4978
                    // leading to hard to debug issues. This is especially relevant for the
4979
                    // discardWorkspaceVersionsOfRecord() call below.
4980
                    ->addOrderBy('uid');
4981
4982
                if ($isPageTranslation) {
4983
                    // Only delete records in the specified language
4984
                    $queryBuilder->where(
4985
                        $queryBuilder->expr()->eq(
4986
                            'pid',
4987
                            $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, \PDO::PARAM_INT)
4988
                        ),
4989
                        $queryBuilder->expr()->eq(
4990
                            $GLOBALS['TCA'][$table]['ctrl']['languageField'],
4991
                            $queryBuilder->createNamedParameter($pageLanguageId, \PDO::PARAM_INT)
4992
                        )
4993
                    );
4994
                } else {
4995
                    // Delete all records on this page
4996
                    $queryBuilder->where(
4997
                        $queryBuilder->expr()->eq(
4998
                            'pid',
4999
                            $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5000
                        )
5001
                    );
5002
                }
5003
5004
                $currentUserWorkspace = (int)$this->BE_USER->workspace;
5005
                if ($currentUserWorkspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5006
                    // If we are in a workspace, make sure only records of this workspace are deleted.
5007
                    $queryBuilder->andWhere(
5008
                        $queryBuilder->expr()->eq(
5009
                            't3ver_wsid',
5010
                            $queryBuilder->createNamedParameter($currentUserWorkspace, \PDO::PARAM_INT)
5011
                        )
5012
                    );
5013
                }
5014
5015
                $statement = $queryBuilder->execute();
5016
5017
                while ($row = $statement->fetch()) {
5018
                    // Delete any further workspace overlays of the record in question, then delete the record.
5019
                    $this->discardWorkspaceVersionsOfRecord($table, $row['uid']);
5020
                    $this->deleteRecord($table, $row['uid'], true, $forceHardDelete);
5021
                }
5022
            }
5023
        }
5024
5025
        // Delete any further workspace overlays of the record in question, then delete the record.
5026
        $this->discardWorkspaceVersionsOfRecord('pages', $uid);
5027
        $this->deleteRecord('pages', $uid, true, $forceHardDelete);
5028
    }
5029
5030
    /**
5031
     * Used to evaluate if a page can be deleted
5032
     *
5033
     * @param int $uid Page id
5034
     * @return int[]|string If array: List of page uids to traverse and delete (means OK), if string: error message.
5035
     * @internal should only be used from within DataHandler
5036
     */
5037
    public function canDeletePage($uid)
5038
    {
5039
        $uid = (int)$uid;
5040
        $isTranslatedPage = null;
5041
5042
        // If we may at all delete this page
5043
        // If this is a page translation, do the check against the perms_* of the default page
5044
        // Because it is currently only deleting the translation
5045
        $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
5046
        if ($defaultLanguagePageId !== $uid) {
5047
            if ($this->doesRecordExist('pages', (int)$defaultLanguagePageId, Permission::PAGE_DELETE)) {
5048
                $isTranslatedPage = true;
5049
            } else {
5050
                return 'Attempt to delete page without permissions';
5051
            }
5052
        } elseif (!$this->doesRecordExist('pages', $uid, Permission::PAGE_DELETE)) {
5053
            return 'Attempt to delete page without permissions';
5054
        }
5055
5056
        $pageIdsInBranch = $this->doesBranchExist('', $uid, Permission::PAGE_DELETE, true);
5057
5058
        if ($pageIdsInBranch === -1) {
5059
            return 'Attempt to delete pages in branch without permissions';
5060
        }
5061
5062
        $pagesInBranch = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
5063
5064
        if ($disallowedTables = $this->checkForRecordsFromDisallowedTables($pagesInBranch)) {
5065
            return 'Attempt to delete records from disallowed tables (' . implode(', ', $disallowedTables) . ')';
5066
        }
5067
5068
        foreach ($pagesInBranch as $pageInBranch) {
5069
            if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, $isTranslatedPage ? false : true)) {
5070
                return 'Attempt to delete page which has prohibited localizations.';
5071
            }
5072
        }
5073
        return $pagesInBranch;
5074
    }
5075
5076
    /**
5077
     * Returns TRUE if record CANNOT be deleted, otherwise FALSE. Used to check before the versioning API allows a record to be marked for deletion.
5078
     *
5079
     * @param string $table Record Table
5080
     * @param int $id Record UID
5081
     * @return string Returns a string IF there is an error (error string explaining). FALSE means record can be deleted
5082
     * @internal should only be used from within DataHandler
5083
     */
5084
    public function cannotDeleteRecord($table, $id)
5085
    {
5086
        if ($table === 'pages') {
5087
            $res = $this->canDeletePage($id);
5088
            return is_array($res) ? false : $res;
0 ignored issues
show
Bug Best Practice introduced by
The expression return is_array($res) ? false : $res could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
5089
        }
5090
        if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5091
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5092
            $perms = Permission::PAGE_EDIT;
5093
        } else {
5094
            $perms = Permission::CONTENT_EDIT;
5095
        }
5096
        return $this->doesRecordExist($table, $id, $perms) ? false : 'No permission to delete record';
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->doesRecord...ssion to delete record' could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
5097
    }
5098
5099
    /**
5100
     * Before a record is deleted, check if it has references such as inline type or MM references.
5101
     * If so, set these child records also to be deleted.
5102
     *
5103
     * @param string $table Record Table
5104
     * @param int $uid Record UID
5105
     * @see deleteRecord()
5106
     * @internal should only be used from within DataHandler
5107
     */
5108
    public function deleteRecord_procFields($table, $uid)
5109
    {
5110
        $conf = $GLOBALS['TCA'][$table]['columns'];
5111
        $row = BackendUtility::getRecord($table, $uid, '*', '', false);
5112
        if (empty($row)) {
5113
            return;
5114
        }
5115
        foreach ($row as $field => $value) {
5116
            $this->deleteRecord_procBasedOnFieldType($table, $uid, $value, $conf[$field]['config'] ?? []);
5117
        }
5118
    }
5119
5120
    /**
5121
     * Process fields of a record to be deleted and search for special handling, like
5122
     * inline type, MM records, etc.
5123
     *
5124
     * @param string $table Record Table
5125
     * @param int $uid Record UID
5126
     * @param string $value Record field value
5127
     * @param array $conf TCA configuration of current field
5128
     * @see deleteRecord()
5129
     * @internal should only be used from within DataHandler
5130
     */
5131
    public function deleteRecord_procBasedOnFieldType($table, $uid, $value, $conf): void
5132
    {
5133
        if (!isset($conf['type'])) {
5134
            return;
5135
        }
5136
        if ($conf['type'] === 'inline') {
5137
            $foreign_table = $conf['foreign_table'];
5138
            if ($foreign_table) {
5139
                $inlineType = $this->getInlineFieldType($conf);
5140
                if ($inlineType === 'list' || $inlineType === 'field') {
5141
                    /** @var RelationHandler $dbAnalysis */
5142
                    $dbAnalysis = $this->createRelationHandlerInstance();
5143
                    $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
5144
                    $dbAnalysis->undeleteRecord = true;
5145
5146
                    $enableCascadingDelete = true;
5147
                    // non type save comparison is intended!
5148
                    if (isset($conf['behaviour']['enableCascadingDelete']) && $conf['behaviour']['enableCascadingDelete'] == false) {
5149
                        $enableCascadingDelete = false;
5150
                    }
5151
5152
                    // Walk through the items and remove them
5153
                    foreach ($dbAnalysis->itemArray as $v) {
5154
                        if ($enableCascadingDelete) {
5155
                            $this->deleteAction($v['table'], $v['id']);
5156
                        }
5157
                    }
5158
                }
5159
            }
5160
        } elseif ($this->isReferenceField($conf)) {
5161
            $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5162
            $dbAnalysis = $this->createRelationHandlerInstance();
5163
            $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $uid, $table, $conf);
5164
            foreach ($dbAnalysis->itemArray as $v) {
5165
                $this->updateRefIndex($v['table'], $v['id']);
5166
            }
5167
        }
5168
    }
5169
5170
    /**
5171
     * Find l10n-overlay records and perform the requested delete action for these records.
5172
     *
5173
     * @param string $table Record Table
5174
     * @param int $uid Record UID
5175
     * @internal should only be used from within DataHandler
5176
     */
5177
    public function deleteL10nOverlayRecords($table, $uid)
5178
    {
5179
        // Check whether table can be localized
5180
        if (!BackendUtility::isTableLocalizable($table)) {
5181
            return;
5182
        }
5183
5184
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5185
        $queryBuilder->getRestrictions()
5186
            ->removeAll()
5187
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
5188
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
5189
5190
        $queryBuilder->select('*')
5191
            ->from($table)
5192
            ->where(
5193
                $queryBuilder->expr()->eq(
5194
                    $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5195
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5196
                )
5197
            );
5198
5199
        $result = $queryBuilder->execute();
5200
        while ($record = $result->fetch()) {
5201
            // Ignore workspace delete placeholders. Those records have been marked for
5202
            // deletion before - deleting them again in a workspace would revert that state.
5203
            if ((int)$this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5204
                BackendUtility::workspaceOL($table, $record, $this->BE_USER->workspace);
5205
                if (VersionState::cast($record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)) {
5206
                    continue;
5207
                }
5208
            }
5209
            $this->deleteAction($table, (int)$record['t3ver_oid'] > 0 ? (int)$record['t3ver_oid'] : (int)$record['uid']);
5210
        }
5211
    }
5212
5213
    /*********************************************
5214
     *
5215
     * Cmd: undelete / restore
5216
     *
5217
     ********************************************/
5218
5219
    /**
5220
     * Restore live records by setting soft-delete flag to 0.
5221
     *
5222
     * Usually only used by ext:recycler.
5223
     * Connected relations (eg. inline) are restored, too.
5224
     * Additional existing localizations are not restored.
5225
     *
5226
     * @param string $table Record table name
5227
     * @param int $uid Record uid
5228
     */
5229
    protected function undeleteRecord(string $table, int $uid): void
5230
    {
5231
        $record = BackendUtility::getRecord($table, $uid, '*', '', false);
5232
        $deleteField = (string)($GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '');
5233
        $timestampField = (string)($GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? '');
5234
5235
        if ($record === null
5236
            || $deleteField === ''
5237
            || !isset($record[$deleteField])
5238
            || (bool)$record[$deleteField] === false
5239
            || ($timestampField !== '' && !isset($record[$timestampField]))
5240
            || (int)$this->BE_USER->workspace > 0
5241
            || (BackendUtility::isTableWorkspaceEnabled($table) && (int)($record['t3ver_wsid'] ?? 0) > 0)
5242
        ) {
5243
            // Return early and silently, if:
5244
            // * Record not found
5245
            // * Table is not soft-delete aware
5246
            // * Record does not have deleted field - db analyzer not up-to-date?
5247
            // * Record is not deleted - may eventually happen via recursion with self referencing records?
5248
            // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date?
5249
            // * User is in a workspace - does not make sense
5250
            // * Record is in a workspace - workspace records are not soft-delete aware
5251
            return;
5252
        }
5253
5254
        $recordPid = (int)($record['pid'] ?? 0);
5255
        if ($recordPid > 0) {
5256
            // Record is not on root level. Parent page record must exist and must not be deleted itself.
5257
            $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5258
            if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
5259
                $this->log(
5260
                    $table,
5261
                    $uid,
5262
                    SystemLogDatabaseAction::DELETE,
5263
                    0,
5264
                    SystemLogErrorClassification::USER_ERROR,
5265
                    sprintf('Record "%s:%s" can\'t be restored: The page:%s containing it does not exist or is soft-deleted.', $table, $uid, $recordPid),
5266
                    0,
5267
                    [],
5268
                    $recordPid
5269
                );
5270
                return;
5271
            }
5272
        }
5273
5274
        // @todo: When restoring a not-default language record, it should be verified the default language
5275
        // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted?
5276
5277
        if (!$this->BE_USER->recordEditAccessInternals($table, $record, false, true)) {
5278
            // User misses access permissions to record
5279
            $this->log(
5280
                $table,
5281
                $uid,
5282
                SystemLogDatabaseAction::DELETE,
5283
                0,
5284
                SystemLogErrorClassification::USER_ERROR,
5285
                sprintf('Record "%s:%s" can\'t be restored: Insufficient user permissions.', $table, $uid),
5286
                0,
5287
                [],
5288
                $recordPid
5289
            );
5290
            return;
5291
        }
5292
5293
        // Restore referenced child records
5294
        $this->undeleteRecordRelations($table, $uid, $record);
5295
5296
        // Restore record
5297
        $updateFields[$deleteField] = 0;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$updateFields was never initialized. Although not strictly required by PHP, it is generally a good practice to add $updateFields = array(); before regardless.
Loading history...
5298
        if ($timestampField !== '') {
5299
            $updateFields[$timestampField] = $GLOBALS['EXEC_TIME'];
5300
        }
5301
        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table)
5302
            ->update(
5303
                $table,
5304
                $updateFields,
5305
                ['uid' => $uid]
5306
            );
5307
5308
        if ($this->enableLogging) {
5309
            $this->log(
5310
                $table,
5311
                $uid,
5312
                SystemLogDatabaseAction::INSERT,
5313
                0,
5314
                SystemLogErrorClassification::MESSAGE,
5315
                sprintf('Record "%s:%s" was restored on page:%s', $table, $uid, $recordPid),
5316
                0,
5317
                [],
5318
                $recordPid
5319
            );
5320
        }
5321
5322
        // Register cache clearing of page, or parent page if a page is restored.
5323
        $this->registerRecordIdForPageCacheClearing($table, $uid, $recordPid);
5324
        // Add history entry
5325
        $this->getRecordHistoryStore()->undeleteRecord($table, $uid, $this->correlationId);
5326
        // Update reference index with table/uid on left side (recuid)
5327
        $this->updateRefIndex($table, $uid);
5328
        // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored.
5329
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, 0);
5330
    }
5331
5332
    /**
5333
     * Check if a to-restore record has inline references and restore them.
5334
     *
5335
     * @param string $table Record table name
5336
     * @param int $uid Record uid
5337
     * @param array $record Record row
5338
     * @todo: Add functional test undelete coverage to verify details, some details seem to be missing.
5339
     */
5340
    protected function undeleteRecordRelations(string $table, int $uid, array $record): void
5341
    {
5342
        foreach ($record as $fieldName => $value) {
5343
            $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'] ?? [];
5344
            $fieldType = (string)($fieldConfig['type'] ?? '');
5345
            if (empty($fieldConfig) || !is_array($fieldConfig) || $fieldType === '') {
5346
                continue;
5347
            }
5348
            $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5349
            if ($fieldType === 'inline') {
5350
                // @todo: Inline MM not handled here, and what about group / select?
5351
                if ($foreignTable === ''
5352
                    || !in_array($this->getInlineFieldType($fieldConfig), ['list', 'field'], true)
5353
                ) {
5354
                    continue;
5355
                }
5356
                $relationHandler = $this->createRelationHandlerInstance();
5357
                $relationHandler->start($value, $foreignTable, '', $uid, $table, $fieldConfig);
5358
                $relationHandler->undeleteRecord = true;
5359
                foreach ($relationHandler->itemArray as $reference) {
5360
                    $this->undeleteRecord($reference['table'], (int)$reference['id']);
5361
                }
5362
            } elseif ($this->isReferenceField($fieldConfig)) {
5363
                $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable;
5364
                $relationHandler = $this->createRelationHandlerInstance();
5365
                $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', $uid, $table, $fieldConfig);
5366
                foreach ($relationHandler->itemArray as $reference) {
5367
                    // @todo: Unsure if this is ok / enough. Needs coverage.
5368
                    $this->updateRefIndex($reference['table'], $reference['id']);
5369
                }
5370
            }
5371
        }
5372
    }
5373
5374
    /*********************************************
5375
     *
5376
     * Cmd: Workspace discard & flush
5377
     *
5378
     ********************************************/
5379
5380
    /**
5381
     * Discard a versioned record from this workspace. This deletes records from the database - no soft delete.
5382
     * This main entry method is called recursive for sub pages, localizations, relations and records on a page.
5383
     * The method checks user access and gathers facts about this record to hand the deletion over to detail methods.
5384
     *
5385
     * The incoming $uid or $row can be anything: The workspace of current user is respected and only records
5386
     * of current user workspace are discarded. If giving a live record uid, the versioned overly will be fetched.
5387
     *
5388
     * @param string $table Database table name
5389
     * @param int|null $uid Uid of live or versioned record to be discarded, or null if $record is given
5390
     * @param array|null $record Record row that should be discarded. Used instead of $uid within recursion.
5391
     * @internal should only be used from within DataHandler
5392
     */
5393
    public function discard(string $table, ?int $uid, array $record = null): void
5394
    {
5395
        if ($uid === null && $record === null) {
5396
            throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491);
5397
        }
5398
5399
        // Fetch record we are dealing with if not given
5400
        if ($record === null) {
5401
            $record = BackendUtility::getRecord($table, (int)$uid);
5402
        }
5403
        if (!is_array($record)) {
5404
            return;
5405
        }
5406
        $uid = (int)$record['uid'];
5407
5408
        // Call hook and return if hook took care of the element
5409
        $recordWasDiscarded = false;
5410
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5411
            $hookObj = GeneralUtility::makeInstance($className);
5412
            if (method_exists($hookObj, 'processCmdmap_discardAction')) {
5413
                $hookObj->processCmdmap_discardAction($table, $uid, $record, $recordWasDiscarded);
5414
            }
5415
        }
5416
5417
        $userWorkspace = (int)$this->BE_USER->workspace;
5418
        if ($recordWasDiscarded
5419
            || $userWorkspace === 0
5420
            || !BackendUtility::isTableWorkspaceEnabled($table)
5421
            || $this->hasDeletedRecord($table, $uid)
5422
        ) {
5423
            return;
5424
        }
5425
5426
        // Gather versioned record
5427
        $versionRecord = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $versionRecord is dead and can be removed.
Loading history...
5428
        if ((int)$record['t3ver_wsid'] === 0) {
5429
            $record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, $uid);
5430
        }
5431
        if (!is_array($record)) {
5432
            return;
5433
        }
5434
        $versionRecord = $record;
5435
5436
        // User access checks
5437
        if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) {
5438
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: Different workspace', SystemLogErrorClassification::USER_ERROR);
5439
            return;
5440
        }
5441
        if ($errorCode = $this->BE_USER->workspaceCannotEditOfflineVersion($table, $versionRecord['uid'])) {
5442
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: ' . $errorCode, SystemLogErrorClassification::USER_ERROR);
5443
            return;
5444
        }
5445
        if (!$this->checkRecordUpdateAccess($table, $versionRecord['uid'])) {
5446
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: User has no edit access', SystemLogErrorClassification::USER_ERROR);
5447
            return;
5448
        }
5449
        $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] !== 0);
5450
        if (!$this->BE_USER->recordEditAccessInternals($table, $versionRecord, false, true, $fullLanguageAccessCheck)) {
5451
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: User has no delete access', SystemLogErrorClassification::USER_ERROR);
5452
            return;
5453
        }
5454
5455
        // Perform discard operations
5456
        $versionState = VersionState::cast($versionRecord['t3ver_state']);
5457
        if ($table === 'pages' && $versionState->equals(VersionState::NEW_PLACEHOLDER)) {
5458
            // When discarding a new page, there can be new sub pages and new records.
5459
            // Those need to be discarded, otherwise they'd end up as records without parent page.
5460
            $this->discardSubPagesAndRecordsOnPage($versionRecord);
5461
        }
5462
5463
        $this->discardLocalizationOverlayRecords($table, $versionRecord);
5464
        $this->discardRecordRelations($table, $versionRecord);
5465
        $this->hardDeleteSingleRecord($table, (int)$versionRecord['uid']);
5466
        $this->deletedRecords[$table][] = (int)$versionRecord['uid'];
5467
        $this->registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace);
5468
        $this->getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId);
5469
        $this->log(
5470
            $table,
5471
            (int)$versionRecord['uid'],
5472
            SystemLogDatabaseAction::DELETE,
5473
            0,
5474
            SystemLogErrorClassification::MESSAGE,
5475
            'Record ' . $table . ':' . $versionRecord['uid'] . ' was deleted unrecoverable from page ' . $versionRecord['pid'],
5476
            0,
5477
            [],
5478
            (int)$versionRecord['pid']
5479
        );
5480
    }
5481
5482
    /**
5483
     * Also discard any sub pages and records of a new parent page if this page is discarded.
5484
     * Discarding only in specific localization, if needed.
5485
     *
5486
     * @param array $page Page record row
5487
     */
5488
    protected function discardSubPagesAndRecordsOnPage(array $page): void
5489
    {
5490
        $isLocalizedPage = false;
5491
        $sysLanguageId = (int)$page[$GLOBALS['TCA']['pages']['ctrl']['languageField']];
5492
        $versionState = VersionState::cast($page['t3ver_state']);
5493
        if ($sysLanguageId > 0) {
5494
            // New or moved localized page.
5495
            // Discard records on this page localization, but no sub pages.
5496
            // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent.
5497
            // @todo: Discard other page translations that inherit from this?! (l10n_source field)
5498
            $isLocalizedPage = true;
5499
            $pid = (int)$page[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
5500
        } elseif ($versionState->equals(VersionState::NEW_PLACEHOLDER)) {
5501
            // New default language page.
5502
            // Discard any sub pages and all other records of this page, including any page localizations.
5503
            // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid
5504
            // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here.
5505
            $pid = (int)$page['uid'];
5506
        } else {
5507
            // Moved default language page.
5508
            // Discard any sub pages and all other records of this page, including any page localizations.
5509
            $pid = (int)$page['t3ver_oid'];
5510
        }
5511
        $tables = $this->compileAdminTables();
5512
        foreach ($tables as $table) {
5513
            if (($isLocalizedPage && $table === 'pages')
5514
                || ($isLocalizedPage && !BackendUtility::isTableLocalizable($table))
5515
                || !BackendUtility::isTableWorkspaceEnabled($table)
5516
            ) {
5517
                continue;
5518
            }
5519
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5520
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5521
            $queryBuilder->select('*')
5522
                ->from($table)
5523
                ->where(
5524
                    $queryBuilder->expr()->eq(
5525
                        'pid',
5526
                        $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)
5527
                    ),
5528
                    $queryBuilder->expr()->eq(
5529
                        't3ver_wsid',
5530
                        $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, \PDO::PARAM_INT)
5531
                    )
5532
                );
5533
            if ($isLocalizedPage) {
5534
                // Add sys_language_uid = x restriction if discarding a localized page
5535
                $queryBuilder->andWhere(
5536
                    $queryBuilder->expr()->eq(
5537
                        $GLOBALS['TCA'][$table]['ctrl']['languageField'],
5538
                        $queryBuilder->createNamedParameter($sysLanguageId, \PDO::PARAM_INT)
5539
                    )
5540
                );
5541
            }
5542
            $statement = $queryBuilder->execute();
5543
            while ($row = $statement->fetch()) {
5544
                $this->discard($table, null, $row);
5545
            }
5546
        }
5547
    }
5548
5549
    /**
5550
     * Discard record relations like inline and MM of a record.
5551
     *
5552
     * @param string $table Table name of this record
5553
     * @param array $record The record row to handle
5554
     */
5555
    protected function discardRecordRelations(string $table, array $record): void
5556
    {
5557
        foreach ($record as $field => $value) {
5558
            $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null;
5559
            if (!isset($fieldConfig['type'])) {
5560
                continue;
5561
            }
5562
            if ($fieldConfig['type'] === 'inline') {
5563
                $foreignTable = $fieldConfig['foreign_table'] ?? null;
5564
                if (!$foreignTable
5565
                     || (isset($fieldConfig['behaviour']['enableCascadingDelete'])
5566
                        && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false)
5567
                ) {
5568
                    continue;
5569
                }
5570
                $inlineType = $this->getInlineFieldType($fieldConfig);
5571
                if ($inlineType === 'list' || $inlineType === 'field') {
5572
                    $dbAnalysis = $this->createRelationHandlerInstance();
5573
                    $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)$record['uid'], $table, $fieldConfig);
5574
                    $dbAnalysis->undeleteRecord = true;
5575
                    foreach ($dbAnalysis->itemArray as $relationRecord) {
5576
                        $this->discard($relationRecord['table'], (int)$relationRecord['id']);
5577
                    }
5578
                }
5579
            } elseif ($this->isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) {
5580
                $this->discardMmRelations($fieldConfig, $record);
5581
            }
5582
            // @todo not inline and not mm - probably not handled correctly and has no proper test coverage yet
5583
        }
5584
    }
5585
5586
    /**
5587
     * When a workspace record row is discarded that has mm relations, existing mm table rows need
5588
     * to be deleted. The method performs the delete operation depending on TCA field configuration.
5589
     *
5590
     * @param array $fieldConfig TCA configuration of this field
5591
     * @param array $record The full record of a left- or ride-side relation
5592
     */
5593
    protected function discardMmRelations(array $fieldConfig, array $record): void
5594
    {
5595
        $recordUid = (int)$record['uid'];
5596
        $mmTableName = $fieldConfig['MM'];
5597
        // left - non foreign - uid_local vs. right - foreign - uid_foreign decision
5598
        $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local';
5599
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
5600
        $queryBuilder->delete($mmTableName)->where(
5601
            // uid_local = given uid OR uid_foreign = given uid
5602
            $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, \PDO::PARAM_INT))
5603
        );
5604
        if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) {
5605
            $queryBuilder->andWhere(
5606
                QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, $fieldConfig['MM_table_where']))
5607
            );
5608
        }
5609
        $mmMatchFields = $fieldConfig['MM_match_fields'] ?? [];
5610
        foreach ($mmMatchFields as $fieldName => $fieldValue) {
5611
            $queryBuilder->andWhere(
5612
                $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue, \PDO::PARAM_STR))
5613
            );
5614
        }
5615
        $queryBuilder->execute();
5616
    }
5617
5618
    /**
5619
     * Find localization overlays of a record and discard them.
5620
     *
5621
     * @param string $table Table of this record
5622
     * @param array $record Record row
5623
     */
5624
    protected function discardLocalizationOverlayRecords(string $table, array $record): void
5625
    {
5626
        if (!BackendUtility::isTableLocalizable($table)) {
5627
            return;
5628
        }
5629
        $uid = (int)$record['uid'];
5630
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5631
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5632
        $statement = $queryBuilder->select('*')
5633
            ->from($table)
5634
            ->where(
5635
                $queryBuilder->expr()->eq(
5636
                    $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5637
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5638
                ),
5639
                $queryBuilder->expr()->eq(
5640
                    't3ver_wsid',
5641
                    $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, \PDO::PARAM_INT)
5642
                )
5643
            )
5644
            ->execute();
5645
        while ($record = $statement->fetch()) {
5646
            $this->discard($table, null, $record);
5647
        }
5648
    }
5649
5650
    /*********************************************
5651
     *
5652
     * Cmd: Versioning
5653
     *
5654
     ********************************************/
5655
    /**
5656
     * Creates a new version of a record
5657
     * (Requires support in the table)
5658
     *
5659
     * @param string $table Table name
5660
     * @param int $id Record uid to versionize
5661
     * @param string $label Version label
5662
     * @param bool $delete If TRUE, the version is created to delete the record.
5663
     * @return int|null Returns the id of the new version (if any)
5664
     * @see copyRecord()
5665
     * @internal should only be used from within DataHandler
5666
     */
5667
    public function versionizeRecord($table, $id, $label, $delete = false)
5668
    {
5669
        $id = (int)$id;
5670
        // Stop any actions if the record is marked to be deleted:
5671
        // (this can occur if IRRE elements are versionized and child elements are removed)
5672
        if ($this->isElementToBeDeleted($table, $id)) {
5673
            return null;
5674
        }
5675
        if (!BackendUtility::isTableWorkspaceEnabled($table) || $id <= 0) {
5676
            $this->newlog('Versioning is not supported for this table "' . $table . '" / ' . $id, SystemLogErrorClassification::USER_ERROR);
5677
            return null;
5678
        }
5679
5680
        // Fetch record with permission check
5681
        $row = $this->recordInfoWithPermissionCheck($table, $id, Permission::PAGE_SHOW);
5682
5683
        // This checks if the record can be selected which is all that a copy action requires.
5684
        if ($row === false) {
5685
            $this->newlog(
5686
                'The record does not exist or you don\'t have correct permissions to make a new version (copy) of this record "' . $table . ':' . $id . '"',
5687
                SystemLogErrorClassification::USER_ERROR
5688
            );
5689
            return null;
5690
        }
5691
5692
        // Record must be online record, otherwise we would create a version of a version
5693
        if (($row['t3ver_oid'] ?? 0) > 0) {
5694
            $this->newlog('Record "' . $table . ':' . $id . '" you wanted to versionize was already a version in archive (record has an online ID)!', SystemLogErrorClassification::USER_ERROR);
5695
            return null;
5696
        }
5697
5698
        if ($delete && $this->cannotDeleteRecord($table, $id)) {
5699
            $this->newlog('Record cannot be deleted: ' . $this->cannotDeleteRecord($table, $id), SystemLogErrorClassification::USER_ERROR);
5700
            return null;
5701
        }
5702
5703
        // Set up the values to override when making a raw-copy:
5704
        $overrideArray = [
5705
            't3ver_oid' => $id,
5706
            't3ver_wsid' => $this->BE_USER->workspace,
5707
            't3ver_state' => (string)($delete ? new VersionState(VersionState::DELETE_PLACEHOLDER) : new VersionState(VersionState::DEFAULT_STATE)),
5708
            't3ver_stage' => 0,
5709
        ];
5710
        if ($GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
5711
            $overrideArray[$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
5712
        }
5713
        // Checking if the record already has a version in the current workspace of the backend user
5714
        $versionRecord = ['uid' => null];
5715
        if ($this->BE_USER->workspace !== 0) {
5716
            // Look for version already in workspace:
5717
            $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid');
5718
        }
5719
        // Create new version of the record and return the new uid
5720
        if (empty($versionRecord['uid'])) {
5721
            // Create raw-copy and return result:
5722
            // The information of the label to be used for the workspace record
5723
            // as well as the information whether the record shall be removed
5724
            // must be forwarded (creating delete placeholders on a workspace are
5725
            // done by copying the record and override several fields).
5726
            $workspaceOptions = [
5727
                'delete' => $delete,
5728
                'label' => $label,
5729
            ];
5730
            return $this->copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions);
5731
        }
5732
        // Reuse the existing record and return its uid
5733
        // (prior to TYPO3 CMS 6.2, an error was thrown here, which
5734
        // did not make much sense since the information is available)
5735
        return $versionRecord['uid'];
5736
    }
5737
5738
    /**
5739
     * Swaps MM-relations for current/swap record, see version_swap()
5740
     *
5741
     * @param string $table Table for the two input records
5742
     * @param int $id Current record (about to go offline)
5743
     * @param int $swapWith Swap record (about to go online)
5744
     * @see version_swap()
5745
     * @internal should only be used from within DataHandler
5746
     */
5747
    public function version_remapMMForVersionSwap($table, $id, $swapWith)
5748
    {
5749
        // Actually, selecting the records fully is only need if flexforms are found inside... This could be optimized ...
5750
        $currentRec = BackendUtility::getRecord($table, $id);
5751
        $swapRec = BackendUtility::getRecord($table, $swapWith);
5752
        $this->version_remapMMForVersionSwap_reg = [];
5753
        $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
5754
        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $fConf) {
5755
            $conf = $fConf['config'];
5756
            if ($this->isReferenceField($conf)) {
5757
                $allowedTables = $conf['type'] === 'group' ? ($conf['allowed'] ?? '') : $conf['foreign_table'];
5758
                $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
5759
                if ($conf['MM'] ?? false) {
5760
                    $dbAnalysis = $this->createRelationHandlerInstance();
5761
                    $dbAnalysis->start('', $allowedTables, $conf['MM'], $id, $table, $conf);
5762
                    if (!empty($dbAnalysis->getValueArray($prependName))) {
0 ignored issues
show
Bug introduced by
It seems like $prependName can also be of type string; however, parameter $prependTableName of TYPO3\CMS\Core\Database\...andler::getValueArray() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

5762
                    if (!empty($dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prependName))) {
Loading history...
5763
                        $this->version_remapMMForVersionSwap_reg[$id][$field] = [$dbAnalysis, $conf['MM'], $prependName];
5764
                    }
5765
                    $dbAnalysis = $this->createRelationHandlerInstance();
5766
                    $dbAnalysis->start('', $allowedTables, $conf['MM'], $swapWith, $table, $conf);
5767
                    if (!empty($dbAnalysis->getValueArray($prependName))) {
5768
                        $this->version_remapMMForVersionSwap_reg[$swapWith][$field] = [$dbAnalysis, $conf['MM'], $prependName];
5769
                    }
5770
                }
5771
            } elseif ($conf['type'] === 'flex') {
5772
                // Current record
5773
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5774
                    $fConf,
5775
                    $table,
5776
                    $field,
5777
                    $currentRec
5778
                );
5779
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5780
                $currentValueArray = GeneralUtility::xml2array($currentRec[$field]);
5781
                if (is_array($currentValueArray)) {
5782
                    $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $id, $field], 'version_remapMMForVersionSwap_flexFormCallBack');
5783
                }
5784
                // Swap record
5785
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5786
                    $fConf,
5787
                    $table,
5788
                    $field,
5789
                    $swapRec
5790
                );
5791
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5792
                $currentValueArray = GeneralUtility::xml2array($swapRec[$field]);
5793
                if (is_array($currentValueArray)) {
5794
                    $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $swapWith, $field], 'version_remapMMForVersionSwap_flexFormCallBack');
5795
                }
5796
            }
5797
        }
5798
        // Execute:
5799
        $this->version_remapMMForVersionSwap_execSwap($table, $id, $swapWith);
5800
    }
5801
5802
    /**
5803
     * Callback function for traversing the FlexForm structure in relation to ...
5804
     *
5805
     * @param array $pParams Array of parameters in num-indexes: table, uid, field
5806
     * @param array $dsConf TCA field configuration (from Data Structure XML)
5807
     * @param string $dataValue The value of the flexForm field
5808
     * @param string $dataValue_ext1 Not used.
5809
     * @param string $dataValue_ext2 Not used.
5810
     * @param string $path Path in flexforms
5811
     * @see version_remapMMForVersionSwap()
5812
     * @see checkValue_flex_procInData_travDS()
5813
     * @internal should only be used from within DataHandler
5814
     */
5815
    public function version_remapMMForVersionSwap_flexFormCallBack($pParams, $dsConf, $dataValue, $dataValue_ext1, $dataValue_ext2, $path)
5816
    {
5817
        // Extract parameters:
5818
        [$table, $uid, $field] = $pParams;
5819
        if ($this->isReferenceField($dsConf)) {
5820
            $allowedTables = $dsConf['type'] === 'group' ? $dsConf['allowed'] : $dsConf['foreign_table'];
5821
            $prependName = $dsConf['type'] === 'group' ? $dsConf['prepend_tname'] : '';
5822
            if ($dsConf['MM']) {
5823
                /** @var RelationHandler $dbAnalysis */
5824
                $dbAnalysis = $this->createRelationHandlerInstance();
5825
                $dbAnalysis->start('', $allowedTables, $dsConf['MM'], $uid, $table, $dsConf);
5826
                $this->version_remapMMForVersionSwap_reg[$uid][$field . '/' . $path] = [$dbAnalysis, $dsConf['MM'], $prependName];
5827
            }
5828
        }
5829
    }
5830
5831
    /**
5832
     * Performing the remapping operations found necessary in version_remapMMForVersionSwap()
5833
     * It must be done in three steps with an intermediate "fake" uid. The UID can be something else than -$id (fx. 9999999+$id if you dare... :-)- as long as it is unique.
5834
     *
5835
     * @param string $table Table for the two input records
5836
     * @param int $id Current record (about to go offline)
5837
     * @param int $swapWith Swap record (about to go online)
5838
     * @see version_remapMMForVersionSwap()
5839
     * @internal should only be used from within DataHandler
5840
     */
5841
    public function version_remapMMForVersionSwap_execSwap($table, $id, $swapWith)
5842
    {
5843
        if (is_array($this->version_remapMMForVersionSwap_reg[$id] ?? false)) {
5844
            foreach ($this->version_remapMMForVersionSwap_reg[$id] as $field => $str) {
5845
                $str[0]->remapMM($str[1], $id, -$id, $str[2]);
5846
            }
5847
        }
5848
        if (is_array($this->version_remapMMForVersionSwap_reg[$swapWith] ?? false)) {
5849
            foreach ($this->version_remapMMForVersionSwap_reg[$swapWith] as $field => $str) {
5850
                $str[0]->remapMM($str[1], $swapWith, $id, $str[2]);
5851
            }
5852
        }
5853
        if (is_array($this->version_remapMMForVersionSwap_reg[$id] ?? false)) {
5854
            foreach ($this->version_remapMMForVersionSwap_reg[$id] as $field => $str) {
5855
                $str[0]->remapMM($str[1], -$id, $swapWith, $str[2]);
5856
            }
5857
        }
5858
    }
5859
5860
    /*********************************************
5861
     *
5862
     * Cmd: Helper functions
5863
     *
5864
     ********************************************/
5865
5866
    /**
5867
     * Returns an instance of DataHandler for handling local datamaps/cmdmaps
5868
     *
5869
     * @return DataHandler
5870
     */
5871
    protected function getLocalTCE()
5872
    {
5873
        $copyTCE = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
5874
        $copyTCE->copyTree = $this->copyTree;
5875
        $copyTCE->enableLogging = $this->enableLogging;
5876
        // Transformations should NOT be carried out during copy
5877
        $copyTCE->dontProcessTransformations = true;
5878
        // make sure the isImporting flag is transferred, so all hooks know if
5879
        // the current process is an import process
5880
        $copyTCE->isImporting = $this->isImporting;
5881
        $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords;
5882
        $copyTCE->bypassWorkspaceRestrictions = $this->bypassWorkspaceRestrictions;
5883
        return $copyTCE;
5884
    }
5885
5886
    /**
5887
     * Processes the fields with references as registered during the copy process. This includes all FlexForm fields which had references.
5888
     * @internal should only be used from within DataHandler
5889
     */
5890
    public function remapListedDBRecords()
5891
    {
5892
        if (!empty($this->registerDBList)) {
5893
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
5894
            foreach ($this->registerDBList as $table => $records) {
5895
                foreach ($records as $uid => $fields) {
5896
                    $newData = [];
5897
                    $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
5898
                    $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate);
5899
                    foreach ($fields as $fieldName => $value) {
5900
                        $conf = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'];
5901
                        switch ($conf['type']) {
5902
                            case 'group':
5903
                            case 'select':
5904
                                $vArray = $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
5905
                                if (is_array($vArray)) {
5906
                                    $newData[$fieldName] = implode(',', $vArray);
5907
                                }
5908
                                break;
5909
                            case 'flex':
5910
                                if ($value === 'FlexForm_reference') {
5911
                                    // This will fetch the new row for the element
5912
                                    $origRecordRow = $this->recordInfo($table, $theUidToUpdate, '*');
5913
                                    if (is_array($origRecordRow)) {
5914
                                        BackendUtility::workspaceOL($table, $origRecordRow);
5915
                                        // Get current data structure and value array:
5916
                                        $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5917
                                            ['config' => $conf],
5918
                                            $table,
5919
                                            $fieldName,
5920
                                            $origRecordRow
5921
                                        );
5922
                                        $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5923
                                        $currentValueArray = GeneralUtility::xml2array($origRecordRow[$fieldName]);
5924
                                        // Do recursive processing of the XML data:
5925
                                        $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $theUidToUpdate, $fieldName], 'remapListedDBRecords_flexFormCallBack');
5926
                                        // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later):
5927
                                        if (is_array($currentValueArray['data'])) {
5928
                                            $newData[$fieldName] = $this->checkValue_flexArray2Xml($currentValueArray, true);
5929
                                        }
5930
                                    }
5931
                                }
5932
                                break;
5933
                            case 'inline':
5934
                                $this->remapListedDBRecords_procInline($conf, $value, $uid, $table);
5935
                                break;
5936
                            default:
5937
                                $this->logger->debug('Field type should not appear here: ' . $conf['type']);
5938
                        }
5939
                    }
5940
                    // If any fields were changed, those fields are updated!
5941
                    if (!empty($newData)) {
5942
                        $this->updateDB($table, $theUidToUpdate_saveTo, $newData);
5943
                    }
5944
                }
5945
            }
5946
        }
5947
    }
5948
5949
    /**
5950
     * Callback function for traversing the FlexForm structure in relation to creating copied files of file relations inside of flex form structures.
5951
     *
5952
     * @param array $pParams Set of parameters in numeric array: table, uid, field
5953
     * @param array $dsConf TCA config for field (from Data Structure of course)
5954
     * @param string $dataValue Field value (from FlexForm XML)
5955
     * @param string $dataValue_ext1 Not used
5956
     * @param string $dataValue_ext2 Not used
5957
     * @return array Array where the "value" key carries the value.
5958
     * @see checkValue_flex_procInData_travDS()
5959
     * @see remapListedDBRecords()
5960
     * @internal should only be used from within DataHandler
5961
     */
5962
    public function remapListedDBRecords_flexFormCallBack($pParams, $dsConf, $dataValue, $dataValue_ext1, $dataValue_ext2)
5963
    {
5964
        // Extract parameters:
5965
        [$table, $uid, $field] = $pParams;
5966
        // If references are set for this field, set flag so they can be corrected later:
5967
        if ($this->isReferenceField($dsConf) && (string)$dataValue !== '') {
5968
            $vArray = $this->remapListedDBRecords_procDBRefs($dsConf, $dataValue, $uid, $table);
5969
            if (is_array($vArray)) {
5970
                $dataValue = implode(',', $vArray);
5971
            }
5972
        }
5973
        // Return
5974
        return ['value' => $dataValue];
5975
    }
5976
5977
    /**
5978
     * Performs remapping of old UID values to NEW uid values for a DB reference field.
5979
     *
5980
     * @param array $conf TCA field config
5981
     * @param string $value Field value
5982
     * @param int $MM_localUid UID of local record (for MM relations - might need to change if support for FlexForms should be done!)
5983
     * @param string $table Table name
5984
     * @return array|null Returns array of items ready to implode for field content.
5985
     * @see remapListedDBRecords()
5986
     * @internal should only be used from within DataHandler
5987
     */
5988
    public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table)
5989
    {
5990
        // Initialize variables
5991
        // Will be set TRUE if an upgrade should be done...
5992
        $set = false;
5993
        // Allowed tables for references.
5994
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5995
        // Table name to prepend the UID
5996
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
5997
        // Which tables that should possibly not be remapped
5998
        $dontRemapTables = GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true);
5999
        // Convert value to list of references:
6000
        $dbAnalysis = $this->createRelationHandlerInstance();
6001
        $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false);
6002
        $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf);
6003
        // Traverse those references and map IDs:
6004
        foreach ($dbAnalysis->itemArray as $k => $v) {
6005
            $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0;
6006
            if ($mapID && !in_array($v['table'], $dontRemapTables, true)) {
6007
                $dbAnalysis->itemArray[$k]['id'] = $mapID;
6008
                $set = true;
6009
            }
6010
        }
6011
        if (!empty($conf['MM'])) {
6012
            // Purge invalid items (live/version)
6013
            $dbAnalysis->purgeItemArray();
6014
            if ($dbAnalysis->isPurged()) {
6015
                $set = true;
6016
            }
6017
6018
            // If record has been versioned/copied in this process, handle invalid relations of the live record
6019
            $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid);
6020
            $originalId = 0;
6021
            if (!empty($this->copyMappingArray_merged[$table])) {
6022
                $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]);
6023
            }
6024
            if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) {
6025
                $liveRelations = $this->createRelationHandlerInstance();
6026
                $liveRelations->setWorkspaceId(0);
6027
                $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf);
6028
                // Purge invalid relations in the live workspace ("0")
6029
                $liveRelations->purgeItemArray(0);
6030
                if ($liveRelations->isPurged()) {
6031
                    $liveRelations->writeMM($conf['MM'], $liveId, $prependName);
0 ignored issues
show
Bug introduced by
It seems like $prependName can also be of type string; however, parameter $prependTableName of TYPO3\CMS\Core\Database\RelationHandler::writeMM() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

6031
                    $liveRelations->writeMM($conf['MM'], $liveId, /** @scrutinizer ignore-type */ $prependName);
Loading history...
6032
                }
6033
            }
6034
        }
6035
        // If a change has been done, set the new value(s)
6036
        if ($set) {
6037
            if ($conf['MM'] ?? false) {
6038
                $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName);
6039
            } else {
6040
                return $dbAnalysis->getValueArray($prependName);
0 ignored issues
show
Bug introduced by
It seems like $prependName can also be of type string; however, parameter $prependTableName of TYPO3\CMS\Core\Database\...andler::getValueArray() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

6040
                return $dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prependName);
Loading history...
6041
            }
6042
        }
6043
        return null;
6044
    }
6045
6046
    /**
6047
     * Performs remapping of old UID values to NEW uid values for an inline field.
6048
     *
6049
     * @param array $conf TCA field config
6050
     * @param string $value Field value
6051
     * @param int $uid The uid of the ORIGINAL record
6052
     * @param string $table Table name
6053
     * @internal should only be used from within DataHandler
6054
     */
6055
    public function remapListedDBRecords_procInline($conf, $value, $uid, $table)
6056
    {
6057
        $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
6058
        if ($conf['foreign_table']) {
6059
            $inlineType = $this->getInlineFieldType($conf);
6060
            if ($inlineType === 'mm') {
6061
                $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6062
            } elseif ($inlineType !== false) {
6063
                /** @var RelationHandler $dbAnalysis */
6064
                $dbAnalysis = $this->createRelationHandlerInstance();
6065
                $dbAnalysis->start($value, $conf['foreign_table'], '', 0, $table, $conf);
6066
6067
                $updatePidForRecords = [];
6068
                // Update values for specific versioned records
6069
                foreach ($dbAnalysis->itemArray as &$item) {
6070
                    $updatePidForRecords[$item['table']][] = $item['id'];
6071
                    $versionedId = $this->getAutoVersionId($item['table'], $item['id']);
6072
                    if ($versionedId !== null) {
6073
                        $updatePidForRecords[$item['table']][] = $versionedId;
6074
                        $item['id'] = $versionedId;
6075
                    }
6076
                }
6077
6078
                // Update child records if using pointer fields ('foreign_field'):
6079
                if ($inlineType === 'field') {
6080
                    $dbAnalysis->writeForeignField($conf, $uid, $theUidToUpdate);
6081
                }
6082
                $thePidToUpdate = null;
6083
                // If the current field is set on a page record, update the pid of related child records:
6084
                if ($table === 'pages') {
6085
                    $thePidToUpdate = $theUidToUpdate;
6086
                } elseif (isset($this->registerDBPids[$table][$uid])) {
6087
                    $thePidToUpdate = $this->registerDBPids[$table][$uid];
6088
                    $thePidToUpdate = $this->copyMappingArray_merged['pages'][$thePidToUpdate];
6089
                }
6090
6091
                // Update child records if change to pid is required
6092
                if ($thePidToUpdate && !empty($updatePidForRecords)) {
6093
                    // Ensure that only the default language page is used as PID
6094
                    $thePidToUpdate = $this->getDefaultLanguagePageId($thePidToUpdate);
6095
                    // @todo: this can probably go away
6096
                    // ensure, only live page ids are used as 'pid' values
6097
                    $liveId = BackendUtility::getLiveVersionIdOfRecord('pages', $theUidToUpdate);
6098
                    if ($liveId !== null) {
6099
                        $thePidToUpdate = $liveId;
6100
                    }
6101
                    $updateValues = ['pid' => $thePidToUpdate];
6102
                    foreach ($updatePidForRecords as $tableName => $uids) {
6103
                        if (empty($tableName) || empty($uids)) {
6104
                            continue;
6105
                        }
6106
                        $conn = GeneralUtility::makeInstance(ConnectionPool::class)
6107
                            ->getConnectionForTable($tableName);
6108
                        foreach ($uids as $updateUid) {
6109
                            $conn->update($tableName, $updateValues, ['uid' => $updateUid]);
6110
                        }
6111
                    }
6112
                }
6113
            }
6114
        }
6115
    }
6116
6117
    /**
6118
     * Processes the $this->remapStack at the end of copying, inserting, etc. actions.
6119
     * The remapStack takes care about the correct mapping of new and old uids in case of relational data.
6120
     * @internal should only be used from within DataHandler
6121
     */
6122
    public function processRemapStack()
6123
    {
6124
        // Processes the remap stack:
6125
        if (is_array($this->remapStack)) {
0 ignored issues
show
introduced by
The condition is_array($this->remapStack) is always true.
Loading history...
6126
            $remapFlexForms = [];
6127
            $hookPayload = [];
6128
6129
            $newValue = null;
6130
            foreach ($this->remapStack as $remapAction) {
6131
                // If no position index for the arguments was set, skip this remap action:
6132
                if (!is_array($remapAction['pos'])) {
6133
                    continue;
6134
                }
6135
                // Load values from the argument array in remapAction:
6136
                $field = $remapAction['field'];
6137
                $id = $remapAction['args'][$remapAction['pos']['id']];
6138
                $rawId = $id;
6139
                $table = $remapAction['args'][$remapAction['pos']['table']];
6140
                $valueArray = $remapAction['args'][$remapAction['pos']['valueArray']];
6141
                $tcaFieldConf = $remapAction['args'][$remapAction['pos']['tcaFieldConf']];
6142
                $additionalData = $remapAction['additionalData'] ?? [];
6143
                // The record is new and has one or more new ids (in case of versioning/workspaces):
6144
                if (strpos($id, 'NEW') !== false) {
6145
                    // Replace NEW...-ID with real uid:
6146
                    $id = $this->substNEWwithIDs[$id];
6147
                    // If the new parent record is on a non-live workspace or versionized, it has another new id:
6148
                    if (isset($this->autoVersionIdMap[$table][$id])) {
6149
                        $id = $this->autoVersionIdMap[$table][$id];
6150
                    }
6151
                    $remapAction['args'][$remapAction['pos']['id']] = $id;
6152
                }
6153
                // Replace relations to NEW...-IDs in field value (uids of child records):
6154
                if (is_array($valueArray)) {
6155
                    foreach ($valueArray as $key => $value) {
6156
                        if (strpos($value, 'NEW') !== false) {
6157
                            if (strpos($value, '_') === false) {
6158
                                $affectedTable = $tcaFieldConf['foreign_table'] ?? '';
6159
                                $prependTable = false;
6160
                            } else {
6161
                                $parts = explode('_', $value);
6162
                                $value = array_pop($parts);
6163
                                $affectedTable = implode('_', $parts);
6164
                                $prependTable = true;
6165
                            }
6166
                            $value = $this->substNEWwithIDs[$value];
6167
                            // The record is new, but was also auto-versionized and has another new id:
6168
                            if (isset($this->autoVersionIdMap[$affectedTable][$value])) {
6169
                                $value = $this->autoVersionIdMap[$affectedTable][$value];
6170
                            }
6171
                            if ($prependTable) {
6172
                                $value = $affectedTable . '_' . $value;
6173
                            }
6174
                            // Set a hint that this was a new child record:
6175
                            $this->newRelatedIDs[$affectedTable][] = $value;
6176
                            $valueArray[$key] = $value;
6177
                        }
6178
                    }
6179
                    $remapAction['args'][$remapAction['pos']['valueArray']] = $valueArray;
6180
                }
6181
                // Process the arguments with the defined function:
6182
                if (!empty($remapAction['func'])) {
6183
                    $callable = [$this, $remapAction['func']];
6184
                    if (is_callable($callable)) {
6185
                        $newValue = call_user_func_array($callable, $remapAction['args']);
6186
                    }
6187
                }
6188
                // If array is returned, check for maxitems condition, if string is returned this was already done:
6189
                if (is_array($newValue)) {
6190
                    $newValue = implode(',', $this->checkValue_checkMax($tcaFieldConf, $newValue));
6191
                    // The reference casting is only required if
6192
                    // checkValue_group_select_processDBdata() returns an array
6193
                    $newValue = $this->castReferenceValue($newValue, $tcaFieldConf);
6194
                }
6195
                // Update in database (list of children (csv) or number of relations (foreign_field)):
6196
                if (!empty($field)) {
6197
                    $fieldArray = [$field => $newValue];
6198
                    if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
6199
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
6200
                    }
6201
                    $this->updateDB($table, $id, $fieldArray);
6202
                } elseif (!empty($additionalData['flexFormId']) && !empty($additionalData['flexFormPath'])) {
6203
                    // Collect data to update FlexForms
6204
                    $flexFormId = $additionalData['flexFormId'];
6205
                    $flexFormPath = $additionalData['flexFormPath'];
6206
6207
                    if (!isset($remapFlexForms[$flexFormId])) {
6208
                        $remapFlexForms[$flexFormId] = [];
6209
                    }
6210
6211
                    $remapFlexForms[$flexFormId][$flexFormPath] = $newValue;
6212
                }
6213
6214
                // Collect elements that shall trigger processDatamap_afterDatabaseOperations
6215
                if (isset($this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'])) {
6216
                    $hookArgs = $this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'];
6217
                    if (!isset($hookPayload[$table][$rawId])) {
6218
                        $hookPayload[$table][$rawId] = [
6219
                            'status' => $hookArgs['status'],
6220
                            'fieldArray' => $hookArgs['fieldArray'],
6221
                            'hookObjects' => $hookArgs['hookObjectsArr'],
6222
                        ];
6223
                    }
6224
                    $hookPayload[$table][$rawId]['fieldArray'][$field] = $newValue;
6225
                }
6226
            }
6227
6228
            if ($remapFlexForms) {
6229
                foreach ($remapFlexForms as $flexFormId => $modifications) {
6230
                    $this->updateFlexFormData((string)$flexFormId, $modifications);
6231
                }
6232
            }
6233
6234
            foreach ($hookPayload as $tableName => $rawIdPayload) {
6235
                foreach ($rawIdPayload as $rawId => $payload) {
6236
                    foreach ($payload['hookObjects'] as $hookObject) {
6237
                        if (!method_exists($hookObject, 'processDatamap_afterDatabaseOperations')) {
6238
                            continue;
6239
                        }
6240
                        $hookObject->processDatamap_afterDatabaseOperations(
6241
                            $payload['status'],
6242
                            $tableName,
6243
                            $rawId,
6244
                            $payload['fieldArray'],
6245
                            $this
6246
                        );
6247
                    }
6248
                }
6249
            }
6250
        }
6251
        // Processes the remap stack actions:
6252
        if ($this->remapStackActions) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->remapStackActions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
6253
            foreach ($this->remapStackActions as $action) {
6254
                if (isset($action['callback'], $action['arguments'])) {
6255
                    call_user_func_array($action['callback'], $action['arguments']);
6256
                }
6257
            }
6258
        }
6259
        // Reset:
6260
        $this->remapStack = [];
6261
        $this->remapStackRecords = [];
6262
        $this->remapStackActions = [];
6263
    }
6264
6265
    /**
6266
     * Updates FlexForm data.
6267
     *
6268
     * @param string $flexFormId e.g. <table>:<uid>:<field>
6269
     * @param array $modifications Modifications with paths and values (e.g. 'sDEF/lDEV/field/vDEF' => 'TYPO3')
6270
     */
6271
    protected function updateFlexFormData($flexFormId, array $modifications)
6272
    {
6273
        [$table, $uid, $field] = explode(':', $flexFormId, 3);
6274
6275
        if (!MathUtility::canBeInterpretedAsInteger($uid) && !empty($this->substNEWwithIDs[$uid])) {
6276
            $uid = $this->substNEWwithIDs[$uid];
6277
        }
6278
6279
        $record = $this->recordInfo($table, $uid, '*');
6280
6281
        if (!$table || !$uid || !$field || !is_array($record)) {
6282
            return;
6283
        }
6284
6285
        BackendUtility::workspaceOL($table, $record);
6286
6287
        // Get current data structure and value array:
6288
        $valueStructure = GeneralUtility::xml2array($record[$field]);
6289
6290
        // Do recursive processing of the XML data:
6291
        foreach ($modifications as $path => $value) {
6292
            $valueStructure['data'] = ArrayUtility::setValueByPath(
6293
                $valueStructure['data'],
6294
                $path,
6295
                $value
6296
            );
6297
        }
6298
6299
        if (is_array($valueStructure['data'])) {
6300
            // The return value should be compiled back into XML
6301
            $values = [
6302
                $field => $this->checkValue_flexArray2Xml($valueStructure, true),
6303
            ];
6304
6305
            $this->updateDB($table, $uid, $values);
6306
        }
6307
    }
6308
6309
    /**
6310
     * Adds an instruction to the remap action stack (used with IRRE).
6311
     *
6312
     * @param string $table The affected table
6313
     * @param int|string $id The affected ID
6314
     * @param callable $callback The callback information (object and method)
6315
     * @param array $arguments The arguments to be used with the callback
6316
     * @internal should only be used from within DataHandler
6317
     */
6318
    public function addRemapAction($table, $id, callable $callback, array $arguments)
6319
    {
6320
        $this->remapStackActions[] = [
6321
            'affects' => [
6322
                'table' => $table,
6323
                'id' => $id
6324
            ],
6325
            'callback' => $callback,
6326
            'arguments' => $arguments
6327
        ];
6328
    }
6329
6330
    /**
6331
     * If a parent record was versionized on a workspace in $this->process_datamap,
6332
     * it might be possible, that child records (e.g. on using IRRE) were affected.
6333
     * This function finds these relations and updates their uids in the $incomingFieldArray.
6334
     * The $incomingFieldArray is updated by reference!
6335
     *
6336
     * @param string $table Table name of the parent record
6337
     * @param int $id Uid of the parent record
6338
     * @param array $incomingFieldArray Reference to the incomingFieldArray of process_datamap
6339
     * @param array $registerDBList Reference to the $registerDBList array that was created/updated by versionizing calls to DataHandler in process_datamap.
6340
     * @internal should only be used from within DataHandler
6341
     */
6342
    public function getVersionizedIncomingFieldArray($table, $id, &$incomingFieldArray, &$registerDBList): void
6343
    {
6344
        if (!isset($registerDBList[$table][$id]) || !is_array($registerDBList[$table][$id])) {
6345
            return;
6346
        }
6347
        foreach ($incomingFieldArray as $field => $value) {
6348
            $foreignTable = $GLOBALS['TCA'][$table]['columns'][$field]['config']['foreign_table'] ?? '';
6349
            if (($registerDBList[$table][$id][$field] ?? false)
6350
                && !empty($foreignTable)
6351
            ) {
6352
                $newValueArray = [];
6353
                $origValueArray = is_array($value) ? $value : explode(',', $value);
6354
                // Update the uids of the copied records, but also take care about new records:
6355
                foreach ($origValueArray as $childId) {
6356
                    $newValueArray[] = $this->autoVersionIdMap[$foreignTable][$childId] ?? $childId;
6357
                }
6358
                // Set the changed value to the $incomingFieldArray
6359
                $incomingFieldArray[$field] = implode(',', $newValueArray);
6360
            }
6361
        }
6362
        // Clean up the $registerDBList array:
6363
        unset($registerDBList[$table][$id]);
6364
        if (empty($registerDBList[$table])) {
6365
            unset($registerDBList[$table]);
6366
        }
6367
    }
6368
6369
    /**
6370
     * Simple helper method to hard delete one row from table ignoring delete TCA field
6371
     *
6372
     * @param string $table A row from this table should be deleted
6373
     * @param int $uid Uid of row to be deleted
6374
     */
6375
    protected function hardDeleteSingleRecord(string $table, int $uid): void
6376
    {
6377
        GeneralUtility::makeInstance(ConnectionPool::class)
6378
            ->getConnectionForTable($table)
6379
            ->delete($table, ['uid' => $uid], [\PDO::PARAM_INT]);
6380
    }
6381
6382
    /*****************************
6383
     *
6384
     * Access control / Checking functions
6385
     *
6386
     *****************************/
6387
    /**
6388
     * Checking group modify_table access list
6389
     *
6390
     * @param string $table Table name
6391
     * @return bool Returns TRUE if the user has general access to modify the $table
6392
     * @internal should only be used from within DataHandler
6393
     */
6394
    public function checkModifyAccessList($table)
6395
    {
6396
        $res = $this->admin || (!$this->tableAdminOnly($table) && isset($this->BE_USER->groupData['tables_modify']) && GeneralUtility::inList($this->BE_USER->groupData['tables_modify'], $table));
6397
        // Hook 'checkModifyAccessList': Post-processing of the state of access
6398
        foreach ($this->getCheckModifyAccessListHookObjects() as $hookObject) {
6399
            /** @var DataHandlerCheckModifyAccessListHookInterface $hookObject */
6400
            $hookObject->checkModifyAccessList($res, $table, $this);
6401
        }
6402
        return $res;
6403
    }
6404
6405
    /**
6406
     * Checking if a record with uid $id from $table is in the BE_USERS webmounts which is required for editing etc.
6407
     *
6408
     * @param string $table Table name
6409
     * @param int $id UID of record
6410
     * @return bool Returns TRUE if OK. Cached results.
6411
     * @internal should only be used from within DataHandler
6412
     */
6413
    public function isRecordInWebMount($table, $id)
6414
    {
6415
        if (!isset($this->isRecordInWebMount_Cache[$table . ':' . $id])) {
6416
            $recP = $this->getRecordProperties($table, $id);
6417
            $this->isRecordInWebMount_Cache[$table . ':' . $id] = $this->isInWebMount($recP['event_pid']);
6418
        }
6419
        return $this->isRecordInWebMount_Cache[$table . ':' . $id];
6420
    }
6421
6422
    /**
6423
     * Checks if the input page ID is in the BE_USER webmounts
6424
     *
6425
     * @param int $pid Page ID to check
6426
     * @return bool TRUE if OK. Cached results.
6427
     * @internal should only be used from within DataHandler
6428
     */
6429
    public function isInWebMount($pid)
6430
    {
6431
        if (!isset($this->isInWebMount_Cache[$pid])) {
6432
            $this->isInWebMount_Cache[$pid] = $this->BE_USER->isInWebMount($pid);
6433
        }
6434
        return $this->isInWebMount_Cache[$pid];
6435
    }
6436
6437
    /**
6438
     * Checks if user may update a record with uid=$id from $table
6439
     *
6440
     * @param string $table Record table
6441
     * @param int $id Record UID
6442
     * @param array|bool $data Record data
6443
     * @param array $hookObjectsArr Hook objects
6444
     * @return bool Returns TRUE if the user may update the record given by $table and $id
6445
     * @internal should only be used from within DataHandler
6446
     */
6447
    public function checkRecordUpdateAccess($table, $id, $data = false, $hookObjectsArr = null)
6448
    {
6449
        $res = null;
6450
        if (is_array($hookObjectsArr)) {
6451
            foreach ($hookObjectsArr as $hookObj) {
6452
                if (method_exists($hookObj, 'checkRecordUpdateAccess')) {
6453
                    $res = $hookObj->checkRecordUpdateAccess($table, $id, $data, $res, $this);
6454
                }
6455
            }
6456
            if (isset($res)) {
6457
                return (bool)$res;
6458
            }
6459
        }
6460
        $res = false;
6461
6462
        if ($GLOBALS['TCA'][$table] && (int)$id > 0) {
6463
            $cacheId = 'checkRecordUpdateAccess_' . $table . '_' . $id;
6464
6465
            // If information is cached, return it
6466
            $cachedValue = $this->runtimeCache->get($cacheId);
6467
            if (!empty($cachedValue)) {
6468
                return $cachedValue;
6469
            }
6470
6471
            if ($table === 'pages' || ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap))) {
6472
                // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
6473
                $perms = Permission::PAGE_EDIT;
6474
            } else {
6475
                $perms = Permission::CONTENT_EDIT;
6476
            }
6477
            if ($this->doesRecordExist($table, $id, $perms)) {
6478
                $res = 1;
6479
            }
6480
            // Cache the result
6481
            $this->runtimeCache->set($cacheId, $res);
6482
        }
6483
        return $res;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $res also could return the type integer which is incompatible with the documented return type boolean.
Loading history...
6484
    }
6485
6486
    /**
6487
     * Checks if user may insert a record from $insertTable on $pid
6488
     *
6489
     * @param string $insertTable Tablename to check
6490
     * @param int $pid Integer PID
6491
     * @param int $action For logging: Action number.
6492
     * @return bool Returns TRUE if the user may insert a record from table $insertTable on page $pid
6493
     * @internal should only be used from within DataHandler
6494
     */
6495
    public function checkRecordInsertAccess($insertTable, $pid, $action = SystemLogDatabaseAction::INSERT)
6496
    {
6497
        $pid = (int)$pid;
6498
        if ($pid < 0) {
6499
            return false;
6500
        }
6501
        // If information is cached, return it
6502
        if (isset($this->recInsertAccessCache[$insertTable][$pid])) {
6503
            return $this->recInsertAccessCache[$insertTable][$pid];
6504
        }
6505
6506
        $res = false;
6507
        if ($insertTable === 'pages') {
6508
            $perms = Permission::PAGE_NEW;
6509
        } elseif (($insertTable === 'sys_file_reference') && array_key_exists('pages', $this->datamap)) {
6510
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
6511
            $perms = Permission::PAGE_EDIT;
6512
        } else {
6513
            $perms = Permission::CONTENT_EDIT;
6514
        }
6515
        $pageExists = (bool)$this->doesRecordExist('pages', $pid, $perms);
6516
        // If either admin and root-level or if page record exists and 1) if 'pages' you may create new ones 2) if page-content, new content items may be inserted on the $pid page
6517
        if ($pageExists || $pid === 0 && ($this->admin || BackendUtility::isRootLevelRestrictionIgnored($insertTable))) {
6518
            // Check permissions
6519
            if ($this->isTableAllowedForThisPage($pid, $insertTable)) {
6520
                $res = true;
6521
                // Cache the result
6522
                $this->recInsertAccessCache[$insertTable][$pid] = $res;
6523
            } elseif ($this->enableLogging) {
6524
                $propArr = $this->getRecordProperties('pages', $pid);
6525
                $this->log($insertTable, $pid, $action, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert record on page \'%s\' (%s) where this table, %s, is not allowed', 11, [$propArr['header'], $pid, $insertTable], $propArr['event_pid']);
6526
            }
6527
        } elseif ($this->enableLogging) {
6528
            $propArr = $this->getRecordProperties('pages', $pid);
6529
            $this->log($insertTable, $pid, $action, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to insert a record on page \'%s\' (%s) from table \'%s\' without permissions. Or non-existing page.', 12, [$propArr['header'], $pid, $insertTable], $propArr['event_pid']);
6530
        }
6531
        return $res;
6532
    }
6533
6534
    /**
6535
     * Checks if a table is allowed on a certain page id according to allowed tables set for the page "doktype" and its [ctrl][rootLevel]-settings if any.
6536
     *
6537
     * @param int $page_uid Page id for which to check, including 0 (zero) if checking for page tree root.
6538
     * @param string $checkTable Table name to check
6539
     * @return bool TRUE if OK
6540
     * @internal should only be used from within DataHandler
6541
     */
6542
    public function isTableAllowedForThisPage($page_uid, $checkTable)
6543
    {
6544
        $page_uid = (int)$page_uid;
6545
        $rootLevelSetting = (int)($GLOBALS['TCA'][$checkTable]['ctrl']['rootLevel'] ?? 0);
6546
        // Check if rootLevel flag is set and we're trying to insert on rootLevel - and reversed - and that the table is not "pages" which are allowed anywhere.
6547
        if ($checkTable !== 'pages' && $rootLevelSetting !== -1 && ($rootLevelSetting xor !$page_uid)) {
6548
            return false;
6549
        }
6550
        $allowed = false;
6551
        // Check root-level
6552
        if (!$page_uid) {
6553
            if ($this->admin || BackendUtility::isRootLevelRestrictionIgnored($checkTable)) {
6554
                $allowed = true;
6555
            }
6556
        } else {
6557
            // Check non-root-level
6558
            $doktype = $this->pageInfo($page_uid, 'doktype');
6559
            $allowedTableList = $GLOBALS['PAGES_TYPES'][$doktype]['allowedTables'] ?? $GLOBALS['PAGES_TYPES']['default']['allowedTables'];
6560
            $allowedArray = GeneralUtility::trimExplode(',', $allowedTableList, true);
6561
            // If all tables or the table is listed as an allowed type, return TRUE
6562
            if (strpos($allowedTableList, '*') !== false || in_array($checkTable, $allowedArray, true)) {
6563
                $allowed = true;
6564
            }
6565
        }
6566
        return $allowed;
6567
    }
6568
6569
    /**
6570
     * Checks if record can be selected based on given permission criteria
6571
     *
6572
     * @param string $table Record table name
6573
     * @param int $id Record UID
6574
     * @param int $perms Permission restrictions to observe: integer that will be bitwise AND'ed.
6575
     * @return bool Returns TRUE if the record given by $table, $id and $perms can be selected
6576
     *
6577
     * @throws \RuntimeException
6578
     * @internal should only be used from within DataHandler
6579
     */
6580
    public function doesRecordExist($table, $id, int $perms)
6581
    {
6582
        return $this->recordInfoWithPermissionCheck($table, $id, $perms, 'uid, pid') !== false;
6583
    }
6584
6585
    /**
6586
     * Looks up a page based on permissions.
6587
     *
6588
     * @param int $id Page id
6589
     * @param int $perms Permission integer
6590
     * @param array $columns Columns to select
6591
     * @return bool|array
6592
     * @internal
6593
     * @see doesRecordExist()
6594
     */
6595
    protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'])
6596
    {
6597
        $permission = new Permission($perms);
6598
        $cacheId = md5('doesRecordExist_pageLookUp_' . $id . '_' . $perms . '_' . implode(
6599
            '_',
6600
            $columns
6601
        ) . '_' . (string)$this->admin);
6602
6603
        // If result is cached, return it
6604
        $cachedResult = $this->runtimeCache->get($cacheId);
6605
        if (!empty($cachedResult)) {
6606
            return $cachedResult;
6607
        }
6608
6609
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6610
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6611
        $queryBuilder
6612
            ->select(...$columns)
6613
            ->from('pages')
6614
            ->where($queryBuilder->expr()->eq(
6615
                'uid',
6616
                $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)
6617
            ));
6618
        if (!$permission->nothingIsGranted() && !$this->admin) {
6619
            $queryBuilder->andWhere($this->BE_USER->getPagePermsClause($perms));
6620
        }
6621
        if (!$this->admin && $GLOBALS['TCA']['pages']['ctrl']['editlock'] &&
6622
            ($permission->editPagePermissionIsGranted() || $permission->deletePagePermissionIsGranted() || $permission->editContentPermissionIsGranted())
6623
        ) {
6624
            $queryBuilder->andWhere($queryBuilder->expr()->eq(
6625
                $GLOBALS['TCA']['pages']['ctrl']['editlock'],
6626
                $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
6627
            ));
6628
        }
6629
6630
        $row = $queryBuilder->execute()->fetch();
6631
        $this->runtimeCache->set($cacheId, $row);
6632
6633
        return $row;
6634
    }
6635
6636
    /**
6637
     * Checks if a whole branch of pages exists
6638
     *
6639
     * Tests the branch under $pid like doesRecordExist(), but it doesn't test the page with $pid as uid - use doesRecordExist() for this purpose.
6640
     * If $recurse is set, the function will follow subpages. This MUST be set, if we need the id-list for deleting pages or else we get an incomplete list
6641
     *
6642
     * @param string $inList List of page uids, this is added to and returned in the end
6643
     * @param int $pid Page ID to select subpages from.
6644
     * @param int $perms Perms integer to check each page record for.
6645
     * @param bool $recurse Recursion flag: If set, it will go out through the branch.
6646
     * @return string|int List of page IDs in branch, if there are subpages, empty string if there are none or -1 if no permission
6647
     * @internal should only be used from within DataHandler
6648
     */
6649
    public function doesBranchExist($inList, $pid, $perms, $recurse)
6650
    {
6651
        $pid = (int)$pid;
6652
        $perms = (int)$perms;
6653
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6654
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6655
        $result = $queryBuilder
6656
            ->select('uid', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody')
6657
            ->from('pages')
6658
            ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)))
6659
            ->orderBy('sorting')
6660
            ->execute();
6661
        while ($row = $result->fetch()) {
6662
            // IF admin, then it's OK
6663
            if ($this->admin || $this->BE_USER->doesUserHaveAccess($row, $perms)) {
6664
                $inList .= $row['uid'] . ',';
6665
                if ($recurse) {
6666
                    // Follow the subpages recursively...
6667
                    $inList = $this->doesBranchExist($inList, $row['uid'], $perms, $recurse);
6668
                    if ($inList === -1) {
6669
                        return -1;
6670
                    }
6671
                }
6672
            } else {
6673
                // No permissions
6674
                return -1;
6675
            }
6676
        }
6677
        return $inList;
6678
    }
6679
6680
    /**
6681
     * Checks if the $table is readOnly
6682
     *
6683
     * @param string $table Table name
6684
     * @return bool TRUE, if readonly
6685
     * @internal should only be used from within DataHandler
6686
     */
6687
    public function tableReadOnly($table)
6688
    {
6689
        // Returns TRUE if table is readonly
6690
        return (bool)($GLOBALS['TCA'][$table]['ctrl']['readOnly'] ?? false);
6691
    }
6692
6693
    /**
6694
     * Checks if the $table is only editable by admin-users
6695
     *
6696
     * @param string $table Table name
6697
     * @return bool TRUE, if readonly
6698
     * @internal should only be used from within DataHandler
6699
     */
6700
    public function tableAdminOnly($table)
6701
    {
6702
        // Returns TRUE if table is admin-only
6703
        return !empty($GLOBALS['TCA'][$table]['ctrl']['adminOnly']);
6704
    }
6705
6706
    /**
6707
     * Checks if page $id is a uid in the rootline of page id $destinationId
6708
     * Used when moving a page
6709
     *
6710
     * @param int $destinationId Destination Page ID to test
6711
     * @param int $id Page ID to test for presence inside Destination
6712
     * @return bool Returns FALSE if ID is inside destination (including equal to)
6713
     * @internal should only be used from within DataHandler
6714
     */
6715
    public function destNotInsideSelf($destinationId, $id)
6716
    {
6717
        $loopCheck = 100;
6718
        $destinationId = (int)$destinationId;
6719
        $id = (int)$id;
6720
        if ($destinationId === $id) {
6721
            return false;
6722
        }
6723
        while ($destinationId !== 0 && $loopCheck > 0) {
6724
            $loopCheck--;
6725
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6726
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6727
            $result = $queryBuilder
6728
                ->select('pid', 'uid', 't3ver_oid', 't3ver_wsid')
6729
                ->from('pages')
6730
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($destinationId, \PDO::PARAM_INT)))
6731
                ->execute();
6732
            if ($row = $result->fetch()) {
6733
                // Ensure that the moved location is used as the PID value
6734
                BackendUtility::workspaceOL('pages', $row, $this->BE_USER->workspace);
6735
                if ($row['pid'] == $id) {
6736
                    return false;
6737
                }
6738
                $destinationId = (int)$row['pid'];
6739
            } else {
6740
                return false;
6741
            }
6742
        }
6743
        return true;
6744
    }
6745
6746
    /**
6747
     * Generate an array of fields to be excluded from editing for the user. Based on "exclude"-field in TCA and a look up in non_exclude_fields
6748
     * Will also generate this list for admin-users so they must be check for before calling the function
6749
     *
6750
     * @return array Array of [table]-[field] pairs to exclude from editing.
6751
     * @internal should only be used from within DataHandler
6752
     */
6753
    public function getExcludeListArray()
6754
    {
6755
        $list = [];
6756
        if (isset($this->BE_USER->groupData['non_exclude_fields'])) {
6757
            $nonExcludeFieldsArray = array_flip(GeneralUtility::trimExplode(',', $this->BE_USER->groupData['non_exclude_fields']));
6758
            foreach ($GLOBALS['TCA'] as $table => $tableConfiguration) {
6759
                if (isset($tableConfiguration['columns'])) {
6760
                    foreach ($tableConfiguration['columns'] as $field => $config) {
6761
                        $isExcludeField = ($config['exclude'] ?? false);
6762
                        $isOnlyVisibleForAdmins = ($GLOBALS['TCA'][$table]['columns'][$field]['displayCond'] ?? '') === 'HIDE_FOR_NON_ADMINS';
6763
                        $editorHasPermissionForThisField = isset($nonExcludeFieldsArray[$table . ':' . $field]);
6764
                        if ($isOnlyVisibleForAdmins || ($isExcludeField && !$editorHasPermissionForThisField)) {
6765
                            $list[] = $table . '-' . $field;
6766
                        }
6767
                    }
6768
                }
6769
            }
6770
        }
6771
6772
        return $list;
6773
    }
6774
6775
    /**
6776
     * Checks if there are records on a page from tables that are not allowed
6777
     *
6778
     * @param int|string $page_uid Page ID
6779
     * @param int $doktype Page doktype
6780
     * @return bool|array Returns a list of the tables that are 'present' on the page but not allowed with the page_uid/doktype
6781
     * @internal should only be used from within DataHandler
6782
     */
6783
    public function doesPageHaveUnallowedTables($page_uid, $doktype)
6784
    {
6785
        $page_uid = (int)$page_uid;
6786
        if (!$page_uid) {
6787
            // Not a number. Probably a new page
6788
            return false;
6789
        }
6790
        $allowedTableList = $GLOBALS['PAGES_TYPES'][$doktype]['allowedTables'] ?? $GLOBALS['PAGES_TYPES']['default']['allowedTables'];
6791
        // If all tables are allowed, return early
6792
        if (strpos($allowedTableList, '*') !== false) {
6793
            return false;
6794
        }
6795
        $allowedArray = GeneralUtility::trimExplode(',', $allowedTableList, true);
6796
        $tableList = [];
6797
        $allTableNames = $this->compileAdminTables();
6798
        foreach ($allTableNames as $table) {
6799
            // If the table is not in the allowed list, check if there are records...
6800
            if (in_array($table, $allowedArray, true)) {
6801
                continue;
6802
            }
6803
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
6804
            $queryBuilder->getRestrictions()->removeAll();
6805
            $count = $queryBuilder
6806
                ->count('uid')
6807
                ->from($table)
6808
                ->where($queryBuilder->expr()->eq(
6809
                    'pid',
6810
                    $queryBuilder->createNamedParameter($page_uid, \PDO::PARAM_INT)
6811
                ))
6812
                ->execute()
6813
                ->fetchColumn(0);
6814
            if ($count) {
6815
                $tableList[] = $table;
6816
            }
6817
        }
6818
        return implode(',', $tableList);
0 ignored issues
show
Bug Best Practice introduced by
The expression return implode(',', $tableList) returns the type string which is incompatible with the documented return type array|boolean.
Loading history...
6819
    }
6820
6821
    /*****************************
6822
     *
6823
     * Information lookup
6824
     *
6825
     *****************************/
6826
    /**
6827
     * Returns the value of the $field from page $id
6828
     * NOTICE; the function caches the result for faster delivery next time. You can use this function repeatedly without performance loss since it doesn't look up the same record twice!
6829
     *
6830
     * @param int $id Page uid
6831
     * @param string $field Field name for which to return value
6832
     * @return string Value of the field. Result is cached in $this->pageCache[$id][$field] and returned from there next time!
6833
     * @internal should only be used from within DataHandler
6834
     */
6835
    public function pageInfo($id, $field)
6836
    {
6837
        if (!isset($this->pageCache[$id])) {
6838
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6839
            $queryBuilder->getRestrictions()->removeAll();
6840
            $row = $queryBuilder
6841
                ->select('*')
6842
                ->from('pages')
6843
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
6844
                ->execute()
6845
                ->fetch();
6846
            if ($row) {
6847
                $this->pageCache[$id] = $row;
6848
            }
6849
        }
6850
        return $this->pageCache[$id][$field];
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->pageCache[$id][$field] returns the type array which is incompatible with the documented return type string.
Loading history...
6851
    }
6852
6853
    /**
6854
     * Returns the row of a record given by $table and $id and $fieldList (list of fields, may be '*')
6855
     * NOTICE: No check for deleted or access!
6856
     *
6857
     * @param string $table Table name
6858
     * @param int $id UID of the record from $table
6859
     * @param string $fieldList Field list for the SELECT query, eg. "*" or "uid,pid,...
6860
     * @return array|null Returns the selected record on success, otherwise NULL.
6861
     * @internal should only be used from within DataHandler
6862
     */
6863
    public function recordInfo($table, $id, $fieldList)
6864
    {
6865
        // Skip, if searching for NEW records or there's no TCA table definition
6866
        if ((int)$id === 0 || !isset($GLOBALS['TCA'][$table])) {
6867
            return null;
6868
        }
6869
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
6870
        $queryBuilder->getRestrictions()->removeAll();
6871
        $result = $queryBuilder
6872
            ->select(...GeneralUtility::trimExplode(',', $fieldList))
6873
            ->from($table)
6874
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
6875
            ->execute()
6876
            ->fetch();
6877
        return $result ?: null;
6878
    }
6879
6880
    /**
6881
     * Checks if record exists with and without permission check and returns that row
6882
     *
6883
     * @param string $table Record table name
6884
     * @param int $id Record UID
6885
     * @param int $perms Permission restrictions to observe: An integer that will be bitwise AND'ed.
6886
     * @param string $fieldList - fields - default is '*'
6887
     * @throws \RuntimeException
6888
     * @return array<string,mixed>|false Row if exists and accessible, false otherwise
6889
     */
6890
    protected function recordInfoWithPermissionCheck(string $table, int $id, int $perms, string $fieldList = '*')
6891
    {
6892
        if ($this->bypassAccessCheckForRecords) {
6893
            $columns = GeneralUtility::trimExplode(',', $fieldList, true);
6894
6895
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
6896
            $queryBuilder->getRestrictions()->removeAll();
6897
6898
            $record = $queryBuilder->select(...$columns)
6899
                ->from($table)
6900
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
6901
                ->execute()
6902
                ->fetch();
6903
6904
            return $record ?: false;
6905
        }
6906
        if (!$perms) {
6907
            throw new \RuntimeException('Internal ERROR: no permissions to check for non-admin user', 1270853920);
6908
        }
6909
        // For all tables: Check if record exists:
6910
        $isWebMountRestrictionIgnored = BackendUtility::isWebMountRestrictionIgnored($table);
6911
        if (is_array($GLOBALS['TCA'][$table]) && $id > 0 && ($this->admin || $isWebMountRestrictionIgnored || $this->isRecordInWebMount($table, $id))) {
6912
            $columns = GeneralUtility::trimExplode(',', $fieldList, true);
6913
            if ($table !== 'pages') {
6914
                // Find record without checking page
6915
                // @todo: This should probably check for editlock
6916
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
6917
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6918
                $output = $queryBuilder
6919
                    ->select(...$columns)
6920
                    ->from($table)
6921
                    ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
6922
                    ->execute()
6923
                    ->fetch();
6924
                // If record found, check page as well:
6925
                if (is_array($output)) {
6926
                    // Looking up the page for record:
6927
                    $pageRec = $this->doesRecordExist_pageLookUp($output['pid'], $perms);
6928
                    // Return TRUE if either a page was found OR if the PID is zero AND the user is ADMIN (in which case the record is at root-level):
6929
                    $isRootLevelRestrictionIgnored = BackendUtility::isRootLevelRestrictionIgnored($table);
6930
                    if (is_array($pageRec) || !$output['pid'] && ($this->admin || $isRootLevelRestrictionIgnored)) {
6931
                        return $output;
6932
                    }
6933
                }
6934
                return false;
6935
            }
6936
            return $this->doesRecordExist_pageLookUp($id, $perms, $columns);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->doesRecord...($id, $perms, $columns) also could return the type boolean which is incompatible with the documented return type array<string,mixed>|false.
Loading history...
6937
        }
6938
        return false;
6939
    }
6940
6941
    /**
6942
     * Returns an array with record properties, like header and pid
6943
     * No check for deleted or access is done!
6944
     * For versionized records, pid is resolved to its live versions pid.
6945
     * Used for logging
6946
     *
6947
     * @param string $table Table name
6948
     * @param int $id Uid of record
6949
     * @param bool $noWSOL If set, no workspace overlay is performed
6950
     * @return array Properties of record
6951
     * @internal should only be used from within DataHandler
6952
     */
6953
    public function getRecordProperties($table, $id, $noWSOL = false)
6954
    {
6955
        $row = $table === 'pages' && !$id ? ['title' => '[root-level]', 'uid' => 0, 'pid' => 0] : $this->recordInfo($table, $id, '*');
6956
        if (!$noWSOL) {
6957
            BackendUtility::workspaceOL($table, $row);
6958
        }
6959
        return $this->getRecordPropertiesFromRow($table, $row);
6960
    }
6961
6962
    /**
6963
     * Returns an array with record properties, like header and pid, based on the row
6964
     *
6965
     * @param string $table Table name
6966
     * @param array $row Input row
6967
     * @return array|null Output array
6968
     * @internal should only be used from within DataHandler
6969
     */
6970
    public function getRecordPropertiesFromRow($table, $row)
6971
    {
6972
        if ($GLOBALS['TCA'][$table]) {
6973
            $liveUid = ($row['t3ver_oid'] ?? null) ? ($row['t3ver_oid'] ?? null) : ($row['uid'] ?? null);
6974
            return [
6975
                'header' => BackendUtility::getRecordTitle($table, $row),
6976
                'pid' => $row['pid'] ?? null,
6977
                'event_pid' => $this->eventPid($table, (int)$liveUid, $row['pid'] ?? null),
6978
                't3ver_state' => BackendUtility::isTableWorkspaceEnabled($table) ? ($row['t3ver_state'] ?? '') : ''
6979
            ];
6980
        }
6981
        return null;
6982
    }
6983
6984
    /**
6985
     * @param string $table
6986
     * @param int $uid
6987
     * @param int $pid
6988
     * @return int
6989
     * @internal should only be used from within DataHandler
6990
     */
6991
    public function eventPid($table, $uid, $pid)
6992
    {
6993
        return $table === 'pages' ? $uid : $pid;
6994
    }
6995
6996
    /*********************************************
6997
     *
6998
     * Storing data to Database Layer
6999
     *
7000
     ********************************************/
7001
    /**
7002
     * Update database record
7003
     * Does not check permissions but expects them to be verified on beforehand
7004
     *
7005
     * @param string $table Record table name
7006
     * @param int $id Record uid
7007
     * @param array $fieldArray Array of field=>value pairs to insert. FIELDS MUST MATCH the database FIELDS. No check is done.
7008
     * @internal should only be used from within DataHandler
7009
     */
7010
    public function updateDB($table, $id, $fieldArray)
7011
    {
7012
        if (is_array($fieldArray) && is_array($GLOBALS['TCA'][$table]) && (int)$id) {
7013
            // Do NOT update the UID field, ever!
7014
            unset($fieldArray['uid']);
7015
            if (!empty($fieldArray)) {
7016
                $fieldArray = $this->insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray);
7017
7018
                $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
7019
7020
                $types = [];
7021
                $platform = $connection->getDatabasePlatform();
7022
                if ($platform instanceof SQLServerPlatform) {
7023
                    // mssql needs to set proper PARAM_LOB and others to update fields
7024
                    $tableDetails = $connection->getSchemaManager()->listTableDetails($table);
7025
                    foreach ($fieldArray as $columnName => $columnValue) {
7026
                        $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType();
7027
                    }
7028
                }
7029
7030
                // Execute the UPDATE query:
7031
                $updateErrorMessage = '';
7032
                try {
7033
                    $connection->update($table, $fieldArray, ['uid' => (int)$id], $types);
7034
                } catch (DBALException $e) {
7035
                    $updateErrorMessage = $e->getPrevious()->getMessage();
7036
                }
7037
                // If succeeds, do...:
7038
                if ($updateErrorMessage === '') {
7039
                    // Update reference index:
7040
                    $this->updateRefIndex($table, $id);
7041
                    // Set History data
7042
                    $historyEntryId = 0;
7043
                    if (isset($this->historyRecords[$table . ':' . $id])) {
7044
                        $historyEntryId = $this->getRecordHistoryStore()->modifyRecord($table, $id, $this->historyRecords[$table . ':' . $id], $this->correlationId);
7045
                    }
7046
                    if ($this->enableLogging) {
7047
                        if ($this->checkStoredRecords) {
7048
                            $newRow = $this->checkStoredRecord($table, $id, $fieldArray, SystemLogDatabaseAction::UPDATE) ?? [];
7049
                        } else {
7050
                            $newRow = $fieldArray;
7051
                            $newRow['uid'] = $id;
7052
                        }
7053
                        // Set log entry:
7054
                        $propArr = $this->getRecordPropertiesFromRow($table, $newRow);
7055
                        $isOfflineVersion = (bool)($newRow['t3ver_oid'] ?? 0);
7056
                        $this->log($table, $id, SystemLogDatabaseAction::UPDATE, $propArr['pid'], SystemLogErrorClassification::MESSAGE, 'Record \'%s\' (%s) was updated.' . ($isOfflineVersion ? ' (Offline version).' : ' (Online).'), 10, [$propArr['header'], $table . ':' . $id, 'history' => $historyEntryId], $propArr['event_pid']);
7057
                    }
7058
                    // Clear cache for relevant pages:
7059
                    $this->registerRecordIdForPageCacheClearing($table, $id);
7060
                    // Unset the pageCache for the id if table was page.
7061
                    if ($table === 'pages') {
7062
                        unset($this->pageCache[$id]);
7063
                    }
7064
                } else {
7065
                    $this->log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'SQL error: \'%s\' (%s)', 12, [$updateErrorMessage, $table . ':' . $id]);
7066
                }
7067
            }
7068
        }
7069
    }
7070
7071
    /**
7072
     * Insert into database
7073
     * Does not check permissions but expects them to be verified on beforehand
7074
     *
7075
     * @param string $table Record table name
7076
     * @param string $id "NEW...." uid string
7077
     * @param array $fieldArray Array of field=>value pairs to insert. FIELDS MUST MATCH the database FIELDS. No check is done. "pid" must point to the destination of the record!
7078
     * @param bool $newVersion Set to TRUE if new version is created.
7079
     * @param int $suggestedUid Suggested UID value for the inserted record. See the array $this->suggestedInsertUids; Admin-only feature
7080
     * @param bool $dontSetNewIdIndex If TRUE, the ->substNEWwithIDs array is not updated. Only useful in very rare circumstances!
7081
     * @return int|null Returns ID on success.
7082
     * @internal should only be used from within DataHandler
7083
     */
7084
    public function insertDB($table, $id, $fieldArray, $newVersion = false, $suggestedUid = 0, $dontSetNewIdIndex = false)
7085
    {
7086
        if (is_array($fieldArray) && is_array($GLOBALS['TCA'][$table]) && isset($fieldArray['pid'])) {
7087
            // Do NOT insert the UID field, ever!
7088
            unset($fieldArray['uid']);
7089
            if (!empty($fieldArray)) {
7090
                // Check for "suggestedUid".
7091
                // This feature is used by the import functionality to force a new record to have a certain UID value.
7092
                // This is only recommended for use when the destination server is a passive mirror of another server.
7093
                // As a security measure this feature is available only for Admin Users (for now)
7094
                $suggestedUid = (int)$suggestedUid;
7095
                if ($this->BE_USER->isAdmin() && $suggestedUid && $this->suggestedInsertUids[$table . ':' . $suggestedUid]) {
7096
                    // When the value of ->suggestedInsertUids[...] is "DELETE" it will try to remove the previous record
7097
                    if ($this->suggestedInsertUids[$table . ':' . $suggestedUid] === 'DELETE') {
7098
                        $this->hardDeleteSingleRecord($table, (int)$suggestedUid);
7099
                    }
7100
                    $fieldArray['uid'] = $suggestedUid;
7101
                }
7102
                $fieldArray = $this->insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray);
7103
                $typeArray = [];
7104
                if (!empty($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'])
7105
                    && array_key_exists($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'], $fieldArray)
7106
                ) {
7107
                    $typeArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = Connection::PARAM_LOB;
7108
                }
7109
                $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
7110
                $insertErrorMessage = '';
7111
                try {
7112
                    // Execute the INSERT query:
7113
                    $connection->insert(
7114
                        $table,
7115
                        $fieldArray,
7116
                        $typeArray
7117
                    );
7118
                } catch (DBALException $e) {
7119
                    $insertErrorMessage = $e->getPrevious()->getMessage();
7120
                }
7121
                // If succees, do...:
7122
                if ($insertErrorMessage === '') {
7123
                    // Set mapping for NEW... -> real uid:
7124
                    // the NEW_id now holds the 'NEW....' -id
7125
                    $NEW_id = $id;
7126
                    $id = $this->postProcessDatabaseInsert($connection, $table, $suggestedUid);
7127
7128
                    if (!$dontSetNewIdIndex) {
7129
                        $this->substNEWwithIDs[$NEW_id] = $id;
7130
                        $this->substNEWwithIDs_table[$NEW_id] = $table;
7131
                    }
7132
                    $newRow = [];
7133
                    if ($this->enableLogging) {
7134
                        // Checking the record is properly saved if configured
7135
                        if ($this->checkStoredRecords) {
7136
                            $newRow = $this->checkStoredRecord($table, $id, $fieldArray, SystemLogDatabaseAction::INSERT) ?? [];
7137
                        } else {
7138
                            $newRow = $fieldArray;
7139
                            $newRow['uid'] = $id;
7140
                        }
7141
                    }
7142
                    // Update reference index:
7143
                    $this->updateRefIndex($table, $id);
7144
7145
                    // Store in history
7146
                    $this->getRecordHistoryStore()->addRecord($table, $id, $newRow, $this->correlationId);
7147
7148
                    if ($newVersion) {
7149
                        if ($this->enableLogging) {
7150
                            $propArr = $this->getRecordPropertiesFromRow($table, $newRow);
7151
                            $this->log($table, $id, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::MESSAGE, 'New version created of table \'%s\', uid \'%s\'. UID of new version is \'%s\'', 10, [$table, $fieldArray['t3ver_oid'], $id], $propArr['event_pid'], $NEW_id);
7152
                        }
7153
                    } else {
7154
                        if ($this->enableLogging) {
7155
                            $propArr = $this->getRecordPropertiesFromRow($table, $newRow);
7156
                            $page_propArr = $this->getRecordProperties('pages', $propArr['pid']);
7157
                            $this->log($table, $id, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::MESSAGE, 'Record \'%s\' (%s) was inserted on page \'%s\' (%s)', 10, [$propArr['header'], $table . ':' . $id, $page_propArr['header'], $newRow['pid']], $newRow['pid'], $NEW_id);
7158
                        }
7159
                        // Clear cache for relevant pages:
7160
                        $this->registerRecordIdForPageCacheClearing($table, $id);
7161
                    }
7162
                    return $id;
7163
                }
7164
                if ($this->enableLogging) {
7165
                    $this->log($table, 0, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'SQL error: \'%s\' (%s)', 12, [$insertErrorMessage, $table . ':' . $id]);
7166
                }
7167
            }
7168
        }
7169
        return null;
7170
    }
7171
7172
    /**
7173
     * Checking stored record to see if the written values are properly updated.
7174
     *
7175
     * @param string $table Record table name
7176
     * @param int $id Record uid
7177
     * @param array $fieldArray Array of field=>value pairs to insert/update
7178
     * @param int $action Action, for logging only.
7179
     * @return array|null Selected row
7180
     * @see insertDB()
7181
     * @see updateDB()
7182
     * @internal should only be used from within DataHandler
7183
     */
7184
    public function checkStoredRecord($table, $id, $fieldArray, $action)
7185
    {
7186
        $id = (int)$id;
7187
        if (is_array($GLOBALS['TCA'][$table]) && $id) {
7188
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7189
            $queryBuilder->getRestrictions()->removeAll();
7190
7191
            $row = $queryBuilder
7192
                ->select('*')
7193
                ->from($table)
7194
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7195
                ->execute()
7196
                ->fetch();
7197
7198
            if (!empty($row)) {
7199
                // Traverse array of values that was inserted into the database and compare with the actually stored value:
7200
                $errors = [];
7201
                foreach ($fieldArray as $key => $value) {
7202
                    if (!$this->checkStoredRecords_loose || $value || $row[$key]) {
7203
                        if (is_float($row[$key])) {
7204
                            // if the database returns the value as double, compare it as double
7205
                            if ((double)$value !== (double)$row[$key]) {
7206
                                $errors[] = $key;
7207
                            }
7208
                        } else {
7209
                            $dbType = $GLOBALS['TCA'][$table]['columns'][$key]['config']['dbType'] ?? false;
7210
                            if ($dbType === 'datetime' || $dbType === 'time') {
7211
                                $row[$key] = $this->normalizeTimeFormat($table, $row[$key], $dbType);
7212
                            }
7213
                            if ((string)$value !== (string)$row[$key]) {
7214
                                // The is_numeric check catches cases where we want to store a float/double value
7215
                                // and database returns the field as a string with the least required amount of
7216
                                // significant digits, i.e. "0.00" being saved and "0" being read back.
7217
                                if (is_numeric($value) && is_numeric($row[$key])) {
7218
                                    if ((double)$value === (double)$row[$key]) {
7219
                                        continue;
7220
                                    }
7221
                                }
7222
                                $errors[] = $key;
7223
                            }
7224
                        }
7225
                    }
7226
                }
7227
                // Set log message if there were fields with unmatching values:
7228
                if (!empty($errors)) {
7229
                    $message = sprintf(
7230
                        'These fields of record %d in table "%s" have not been saved correctly: %s! The values might have changed due to type casting of the database.',
7231
                        $id,
7232
                        $table,
7233
                        implode(', ', $errors)
7234
                    );
7235
                    $this->log($table, $id, $action, 0, SystemLogErrorClassification::USER_ERROR, $message);
7236
                }
7237
                // Return selected rows:
7238
                return $row;
7239
            }
7240
        }
7241
        return null;
7242
    }
7243
7244
    /**
7245
     * Setting sys_history record, based on content previously set in $this->historyRecords[$table . ':' . $id] (by compareFieldArrayWithCurrentAndUnset())
7246
     *
7247
     * This functionality is now moved into the RecordHistoryStore and can be used instead.
7248
     *
7249
     * @param string $table Table name
7250
     * @param int $id Record ID
7251
     * @internal should only be used from within DataHandler
7252
     */
7253
    public function setHistory($table, $id)
7254
    {
7255
        if (isset($this->historyRecords[$table . ':' . $id])) {
7256
            $this->getRecordHistoryStore()->modifyRecord(
7257
                $table,
7258
                $id,
7259
                $this->historyRecords[$table . ':' . $id],
7260
                $this->correlationId
7261
            );
7262
        }
7263
    }
7264
7265
    /**
7266
     * @return RecordHistoryStore
7267
     */
7268
    protected function getRecordHistoryStore(): RecordHistoryStore
7269
    {
7270
        return GeneralUtility::makeInstance(
7271
            RecordHistoryStore::class,
7272
            RecordHistoryStore::USER_BACKEND,
7273
            $this->BE_USER->user['uid'],
7274
            (int)$this->BE_USER->getOriginalUserIdWhenInSwitchUserMode(),
7275
            $GLOBALS['EXEC_TIME'],
7276
            $this->BE_USER->workspace
7277
        );
7278
    }
7279
7280
    /**
7281
     * Register a table/uid combination in current user workspace for reference updating.
7282
     * Should be called on almost any update to a record which could affect references inside the record.
7283
     *
7284
     * @param string $table Table name
7285
     * @param int $uid Record UID
7286
     * @param int $workspace Workspace the record lives in
7287
     * @internal should only be used from within DataHandler
7288
     */
7289
    public function updateRefIndex($table, $uid, int $workspace = null): void
7290
    {
7291
        if ($workspace === null) {
7292
            $workspace = (int)$this->BE_USER->workspace;
7293
        }
7294
        $this->referenceIndexUpdater->registerForUpdate((string)$table, (int)$uid, $workspace);
7295
    }
7296
7297
    /**
7298
     * Delete rows from sys_refindex a table / uid combination is involved in:
7299
     * Either on left side (tablename + recuid) OR right side (ref_table + ref_uid).
7300
     * Useful in scenarios like workspace-discard where parents or children are hard deleted: The
7301
     * expensive updateRefIndex() does not need to be called since we can just drop straight ahead.
7302
     *
7303
     * @param string $table Table name, used as tablename and ref_table
7304
     * @param int $uid Record uid, used as recuid and ref_uid
7305
     * @param int $workspace Workspace the record lives in
7306
     */
7307
    public function registerReferenceIndexRowsForDrop(string $table, int $uid, int $workspace): void
7308
    {
7309
        $this->referenceIndexUpdater->registerForDrop($table, $uid, $workspace);
7310
    }
7311
7312
    /*********************************************
7313
     *
7314
     * Misc functions
7315
     *
7316
     ********************************************/
7317
    /**
7318
     * Returning sorting number for tables with a "sortby" column
7319
     * Using when new records are created and existing records are moved around.
7320
     *
7321
     * The strategy is:
7322
     *  - if no record exists: set interval as sorting number
7323
     *  - if inserted before an element: put in the middle of the existing elements
7324
     *  - if inserted behind the last element: add interval to last sorting number
7325
     *  - if collision: move all subsequent records by 2 * interval, insert new record with collision + interval
7326
     *
7327
     * How to calculate the maximum possible inserts for the worst case of adding all records to the top,
7328
     * such that the sorting number stays within INT_MAX
7329
     *
7330
     * i = interval (currently 256)
7331
     * c = number of inserts until collision
7332
     * s = max sorting number to reach (INT_MAX - 32bit)
7333
     * n = number of records (~83 million)
7334
     *
7335
     * c = 2 * g
7336
     * g = log2(i) / 2 + 1
7337
     * n = g * s / i - g + 1
7338
     *
7339
     * The algorithm can be tuned by adjusting the interval value.
7340
     * Higher value means less collisions, but also less inserts are possible to stay within INT_MAX.
7341
     *
7342
     * @param string $table Table name
7343
     * @param int $uid Uid of record to find sorting number for. May be zero in case of new.
7344
     * @param int $pid Positioning PID, either >=0 (pointing to page in which case we find sorting number for first record in page) or <0 (pointing to record in which case to find next sorting number after this record)
7345
     * @return int|array|bool|null Returns integer if PID is >=0, otherwise an array with PID and sorting number. Possibly FALSE in case of error.
7346
     * @internal should only be used from within DataHandler
7347
     */
7348
    public function getSortNumber($table, $uid, $pid)
7349
    {
7350
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
7351
        if (!$sortColumn) {
7352
            return null;
7353
        }
7354
7355
        $considerWorkspaces = BackendUtility::isTableWorkspaceEnabled($table);
7356
        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
7357
        $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
7358
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7359
7360
        $queryBuilder
7361
            ->select($sortColumn, 'pid', 'uid')
7362
            ->from($table);
7363
        if ($considerWorkspaces) {
7364
            $queryBuilder->addSelect('t3ver_state');
7365
        }
7366
7367
        // find and return the sorting value for the first record on that pid
7368
        if ($pid >= 0) {
7369
            // Fetches the first record (lowest sorting) under this pid
7370
            $queryBuilder
7371
                ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)));
7372
7373
            if ($considerWorkspaces) {
7374
                $queryBuilder->andWhere(
7375
                    $queryBuilder->expr()->orX(
7376
                        $queryBuilder->expr()->eq('t3ver_oid', 0),
7377
                        $queryBuilder->expr()->eq('t3ver_state', VersionState::MOVE_POINTER)
7378
                    )
7379
                );
7380
            }
7381
            $row = $queryBuilder
7382
                ->orderBy($sortColumn, 'ASC')
7383
                ->addOrderBy('uid', 'ASC')
7384
                ->setMaxResults(1)
7385
                ->execute()
7386
                ->fetch();
7387
7388
            if (!empty($row)) {
7389
                // The top record was the record itself, so we return its current sorting value
7390
                if ($row['uid'] == $uid) {
7391
                    return $row[$sortColumn];
7392
                }
7393
                // If the record sorting value < 1 we must resort all the records under this pid
7394
                if ($row[$sortColumn] < 1) {
7395
                    $this->increaseSortingOfFollowingRecords($table, (int)$pid);
7396
                    // Lowest sorting value after full resorting is $sortIntervals
7397
                    return $this->sortIntervals;
7398
                }
7399
                // Sorting number between current top element and zero
7400
                return floor($row[$sortColumn] / 2);
7401
            }
7402
            // No records, so we choose the default value as sorting-number
7403
            return $this->sortIntervals;
7404
        }
7405
7406
        // Find and return first possible sorting value AFTER record with given uid ($pid)
7407
        // Fetches the record which is supposed to be the prev record
7408
        $row = $queryBuilder
7409
                ->where($queryBuilder->expr()->eq(
7410
                    'uid',
7411
                    $queryBuilder->createNamedParameter(abs($pid), \PDO::PARAM_INT)
7412
                ))
7413
                ->execute()
7414
                ->fetch();
7415
7416
        // There is a previous record
7417
        if (!empty($row)) {
7418
            // Look if the record UID happens to be a versioned record. If so, find its live version.
7419
            // If this is already a moved record in workspace, this is not needed
7420
            if ((int)$row['t3ver_state'] !== VersionState::MOVE_POINTER && $lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, $row['uid'], $sortColumn . ',pid,uid')) {
7421
                $row = $lookForLiveVersion;
7422
            } elseif ($considerWorkspaces && $this->BE_USER->workspace > 0) {
7423
                // In case the previous record is moved in the workspace, we need to fetch the information from this specific record
7424
                $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $row['uid'], $sortColumn . ',pid,uid,t3ver_state');
7425
                if (is_array($versionedRecord) && (int)$versionedRecord['t3ver_state'] === VersionState::MOVE_POINTER) {
7426
                    $row = $versionedRecord;
7427
                }
7428
            }
7429
            // If the record should be inserted after itself, keep the current sorting information:
7430
            if ((int)$row['uid'] === (int)$uid) {
7431
                $sortNumber = $row[$sortColumn];
7432
            } else {
7433
                $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
7434
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7435
7436
                $queryBuilder
7437
                        ->select($sortColumn, 'pid', 'uid')
7438
                        ->from($table)
7439
                        ->where(
7440
                            $queryBuilder->expr()->eq(
7441
                                'pid',
7442
                                $queryBuilder->createNamedParameter($row['pid'], \PDO::PARAM_INT)
7443
                            ),
7444
                            $queryBuilder->expr()->gte(
7445
                                $sortColumn,
7446
                                $queryBuilder->createNamedParameter($row[$sortColumn], \PDO::PARAM_INT)
7447
                            )
7448
                        )
7449
                        ->orderBy($sortColumn, 'ASC')
7450
                        ->addOrderBy('uid', 'DESC')
7451
                        ->setMaxResults(2);
7452
7453
                if ($considerWorkspaces) {
7454
                    $queryBuilder->andWhere(
7455
                        $queryBuilder->expr()->orX(
7456
                            $queryBuilder->expr()->eq('t3ver_oid', 0),
7457
                            $queryBuilder->expr()->eq('t3ver_state', VersionState::MOVE_POINTER)
7458
                        )
7459
                    );
7460
                }
7461
7462
                $subResults = $queryBuilder
7463
                    ->execute()
7464
                    ->fetchAll();
7465
                // Fetches the next record in order to calculate the in-between sortNumber
7466
                // There was a record afterwards
7467
                if (count($subResults) === 2) {
7468
                    // There was a record afterwards, fetch that
7469
                    $subrow = array_pop($subResults);
7470
                    // The sortNumber is found in between these values
7471
                    $sortNumber = $row[$sortColumn] + floor(($subrow[$sortColumn] - $row[$sortColumn]) / 2);
7472
                    // The sortNumber happened NOT to be between the two surrounding numbers, so we'll have to resort the list
7473
                    if ($sortNumber <= $row[$sortColumn] || $sortNumber >= $subrow[$sortColumn]) {
7474
                        $this->increaseSortingOfFollowingRecords($table, (int)$row['pid'], (int)$row[$sortColumn]);
7475
                        $sortNumber = $row[$sortColumn] + $this->sortIntervals;
7476
                    }
7477
                } else {
7478
                    // If after the last record in the list, we just add the sortInterval to the last sortvalue
7479
                    $sortNumber = $row[$sortColumn] + $this->sortIntervals;
7480
                }
7481
            }
7482
            return ['pid' => $row['pid'], 'sortNumber' => $sortNumber];
7483
        }
7484
        if ($this->enableLogging) {
7485
            $propArr = $this->getRecordProperties($table, $uid);
7486
            // OK, don't insert $propArr['event_pid'] here...
7487
            $this->log($table, $uid, SystemLogDatabaseAction::MOVE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to move record \'%s\' (%s) to after a non-existing record (uid=%s)', 1, [$propArr['header'], $table . ':' . $uid, abs($pid)], $propArr['pid']);
7488
        }
7489
        // There MUST be a previous record or else this cannot work
7490
        return false;
7491
    }
7492
7493
    /**
7494
     * Increases sorting field value of all records with sorting higher than $sortingValue
7495
     *
7496
     * Used internally by getSortNumber() to "make space" in sorting values when inserting new record
7497
     *
7498
     * @param string $table Table name
7499
     * @param int $pid Page Uid in which to resort records
7500
     * @param int $sortingValue All sorting numbers larger than this number will be shifted
7501
     * @see getSortNumber()
7502
     */
7503
    protected function increaseSortingOfFollowingRecords(string $table, int $pid, int $sortingValue = null): void
7504
    {
7505
        $sortBy = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
7506
        if ($sortBy) {
7507
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7508
7509
            $queryBuilder
7510
                ->update($table)
7511
                ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)))
7512
                ->set($sortBy, $queryBuilder->quoteIdentifier($sortBy) . ' + ' . $this->sortIntervals . ' + ' . $this->sortIntervals, false);
7513
            if ($sortingValue !== null) {
7514
                $queryBuilder->andWhere($queryBuilder->expr()->gt($sortBy, $sortingValue));
7515
            }
7516
            if (BackendUtility::isTableWorkspaceEnabled($table)) {
7517
                $queryBuilder
7518
                    ->andWhere(
7519
                        $queryBuilder->expr()->eq('t3ver_oid', 0)
7520
                    );
7521
            }
7522
7523
            $deleteColumn = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '';
7524
            if ($deleteColumn) {
7525
                $queryBuilder->andWhere($queryBuilder->expr()->eq($deleteColumn, 0));
7526
            }
7527
7528
            $queryBuilder->execute();
7529
        }
7530
    }
7531
7532
    /**
7533
     * Returning uid of previous localized record, if any, for tables with a "sortby" column
7534
     * Used when new localized records are created so that localized records are sorted in the same order as the default language records
7535
     *
7536
     * For a given record (A) uid (record we're translating) it finds first default language record (from the same colpos)
7537
     * with sorting smaller than given record (B).
7538
     * Then it fetches a translated version of record B and returns it's uid.
7539
     *
7540
     * If there is no record B, or it has no translation in given language, the record A uid is returned.
7541
     * The localized record will be placed the after record which uid is returned.
7542
     *
7543
     * @param string $table Table name
7544
     * @param int $uid Uid of default language record
7545
     * @param int $pid Pid of default language record
7546
     * @param int $language Language of localization
7547
     * @return int uid of record after which the localized record should be inserted
7548
     */
7549
    protected function getPreviousLocalizedRecordUid($table, $uid, $pid, $language)
7550
    {
7551
        $previousLocalizedRecordUid = $uid;
7552
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
7553
        if ($sortColumn) {
7554
            $select = [$sortColumn, 'pid', 'uid'];
7555
            // For content elements, we also need the colPos
7556
            if ($table === 'tt_content') {
7557
                $select[] = 'colPos';
7558
            }
7559
            // Get the sort value of the default language record
7560
            $row = BackendUtility::getRecord($table, $uid, implode(',', $select));
7561
            if (is_array($row)) {
7562
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7563
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7564
7565
                $queryBuilder
7566
                    ->select(...$select)
7567
                    ->from($table)
7568
                    ->where(
7569
                        $queryBuilder->expr()->eq(
7570
                            'pid',
7571
                            $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)
7572
                        ),
7573
                        $queryBuilder->expr()->eq(
7574
                            $GLOBALS['TCA'][$table]['ctrl']['languageField'],
7575
                            $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
7576
                        ),
7577
                        $queryBuilder->expr()->lt(
7578
                            $sortColumn,
7579
                            $queryBuilder->createNamedParameter($row[$sortColumn], \PDO::PARAM_INT)
7580
                        )
7581
                    )
7582
                    ->orderBy($sortColumn, 'DESC')
7583
                    ->addOrderBy('uid', 'DESC')
7584
                    ->setMaxResults(1);
7585
                if ($table === 'tt_content') {
7586
                    $queryBuilder
7587
                        ->andWhere(
7588
                            $queryBuilder->expr()->eq(
7589
                                'colPos',
7590
                                $queryBuilder->createNamedParameter($row['colPos'], \PDO::PARAM_INT)
7591
                            )
7592
                        );
7593
                }
7594
                // If there is an element, find its localized record in specified localization language on this page
7595
                if ($previousRow = $queryBuilder->execute()->fetch()) {
7596
                    $previousLocalizedRecord = BackendUtility::getRecordLocalization($table, $previousRow['uid'], $language, 'pid=' . (int)$pid);
7597
                    if (isset($previousLocalizedRecord[0]) && is_array($previousLocalizedRecord[0])) {
7598
                        $previousLocalizedRecordUid = $previousLocalizedRecord[0]['uid'];
7599
                    }
7600
                }
7601
            }
7602
        }
7603
        return $previousLocalizedRecordUid;
7604
    }
7605
7606
    /**
7607
     * Returns a fieldArray with default values. Values will be picked up from the TCA array looking at the config key "default" for each column. If values are set in ->defaultValues they will overrule though.
7608
     * Used for new records and during copy operations for defaults
7609
     *
7610
     * @param string $table Table name for which to set default values.
7611
     * @return array Array with default values.
7612
     * @internal should only be used from within DataHandler
7613
     */
7614
    public function newFieldArray($table)
7615
    {
7616
        $fieldArray = [];
7617
        if (is_array($GLOBALS['TCA'][$table]['columns'])) {
7618
            foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $content) {
7619
                if (isset($this->defaultValues[$table][$field])) {
7620
                    $fieldArray[$field] = $this->defaultValues[$table][$field];
7621
                } elseif (isset($content['config']['default'])) {
7622
                    $fieldArray[$field] = $content['config']['default'];
7623
                }
7624
            }
7625
        }
7626
        return $fieldArray;
7627
    }
7628
7629
    /**
7630
     * If a "languageField" is specified for $table this function will add a possible value to the incoming array if none is found in there already.
7631
     *
7632
     * @param string $table Table name
7633
     * @param array $incomingFieldArray Incoming array (passed by reference)
7634
     * @internal should only be used from within DataHandler
7635
     */
7636
    public function addDefaultPermittedLanguageIfNotSet($table, &$incomingFieldArray)
7637
    {
7638
        // Checking languages:
7639
        if ($GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? false) {
7640
            if (!isset($incomingFieldArray[$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
7641
                // Language field must be found in input row - otherwise it does not make sense.
7642
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
7643
                    ->getQueryBuilderForTable('sys_language');
7644
                $queryBuilder->getRestrictions()
7645
                    ->removeAll()
7646
                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
7647
                $queryBuilder
7648
                    ->select('uid')
7649
                    ->from('sys_language')
7650
                    ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)));
7651
                $rows = array_merge([['uid' => 0]], $queryBuilder->execute()->fetchAll(), [['uid' => -1]]);
7652
                foreach ($rows as $r) {
7653
                    if ($this->BE_USER->checkLanguageAccess($r['uid'])) {
7654
                        $incomingFieldArray[$GLOBALS['TCA'][$table]['ctrl']['languageField']] = $r['uid'];
7655
                        break;
7656
                    }
7657
                }
7658
            }
7659
        }
7660
    }
7661
7662
    /**
7663
     * Returns the $data array from $table overridden in the fields defined in ->overrideValues.
7664
     *
7665
     * @param string $table Table name
7666
     * @param array $data Data array with fields from table. These will be overlaid with values in $this->overrideValues[$table]
7667
     * @return array Data array, processed.
7668
     * @internal should only be used from within DataHandler
7669
     */
7670
    public function overrideFieldArray($table, $data)
7671
    {
7672
        if (isset($this->overrideValues[$table]) && is_array($this->overrideValues[$table])) {
7673
            $data = array_merge($data, $this->overrideValues[$table]);
7674
        }
7675
        return $data;
7676
    }
7677
7678
    /**
7679
     * Compares the incoming field array with the current record and unsets all fields which are the same.
7680
     * Used for existing records being updated
7681
     *
7682
     * @param string $table Record table name
7683
     * @param int $id Record uid
7684
     * @param array $fieldArray Array of field=>value pairs intended to be inserted into the database. All keys with values matching exactly the current value will be unset!
7685
     * @return array Returns $fieldArray. If the returned array is empty, then the record should not be updated!
7686
     * @internal should only be used from within DataHandler
7687
     */
7688
    public function compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray)
7689
    {
7690
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
7691
        $queryBuilder = $connection->createQueryBuilder();
7692
        $queryBuilder->getRestrictions()->removeAll();
7693
        $currentRecord = $queryBuilder->select('*')
7694
            ->from($table)
7695
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7696
            ->execute()
7697
            ->fetch();
7698
        // If the current record exists (which it should...), begin comparison:
7699
        if (is_array($currentRecord)) {
7700
            $tableDetails = $connection->getSchemaManager()->listTableDetails($table);
7701
            $columnRecordTypes = [];
7702
            foreach ($currentRecord as $columnName => $_) {
7703
                $columnRecordTypes[$columnName] = '';
7704
                $type = $tableDetails->getColumn($columnName)->getType();
7705
                if ($type instanceof IntegerType) {
7706
                    $columnRecordTypes[$columnName] = 'int';
7707
                }
7708
            }
7709
            // Unset the fields which are similar:
7710
            foreach ($fieldArray as $col => $val) {
7711
                $fieldConfiguration = $GLOBALS['TCA'][$table]['columns'][$col]['config'] ?? [];
7712
                $isNullField = (!empty($fieldConfiguration['eval']) && GeneralUtility::inList($fieldConfiguration['eval'], 'null'));
7713
7714
                // Unset fields if stored and submitted values are equal - except the current field holds MM relations.
7715
                // In general this avoids to store superfluous data which also will be visualized in the editing history.
7716
                if (empty($fieldConfiguration['MM']) && $this->isSubmittedValueEqualToStoredValue($val, $currentRecord[$col], $columnRecordTypes[$col], $isNullField)) {
7717
                    unset($fieldArray[$col]);
7718
                } else {
7719
                    if (!isset($this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col])) {
7720
                        $this->historyRecords[$table . ':' . $id]['oldRecord'][$col] = $currentRecord[$col];
7721
                    } elseif ($this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col] != $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col]) {
7722
                        $this->historyRecords[$table . ':' . $id]['oldRecord'][$col] = $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col];
7723
                    }
7724
                    if (!isset($this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col])) {
7725
                        $this->historyRecords[$table . ':' . $id]['newRecord'][$col] = $fieldArray[$col];
7726
                    } elseif ($this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col] != $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col]) {
7727
                        $this->historyRecords[$table . ':' . $id]['newRecord'][$col] = $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col];
7728
                    }
7729
                }
7730
            }
7731
        } else {
7732
            // If the current record does not exist this is an error anyways and we just return an empty array here.
7733
            $fieldArray = [];
7734
        }
7735
        return $fieldArray;
7736
    }
7737
7738
    /**
7739
     * Determines whether submitted values and stored values are equal.
7740
     * This prevents from adding superfluous field changes which would be shown in the record history as well.
7741
     * For NULL fields (see accordant TCA definition 'eval' = 'null'), a special handling is required since
7742
     * (!strcmp(NULL, '')) would be a false-positive.
7743
     *
7744
     * @param mixed $submittedValue Value that has submitted (e.g. from a backend form)
7745
     * @param mixed $storedValue Value that is currently stored in the database
7746
     * @param string $storedType SQL type of the stored value column (see mysql_field_type(), e.g 'int', 'string',  ...)
7747
     * @param bool $allowNull Whether NULL values are allowed by accordant TCA definition ('eval' = 'null')
7748
     * @return bool Whether both values are considered to be equal
7749
     */
7750
    protected function isSubmittedValueEqualToStoredValue($submittedValue, $storedValue, $storedType, $allowNull = false)
7751
    {
7752
        // No NULL values are allowed, this is the regular behaviour.
7753
        // Thus, check whether strings are the same or whether integer values are empty ("0" or "").
7754
        if (!$allowNull) {
7755
            $result = (string)$submittedValue === (string)$storedValue || $storedType === 'int' && (int)$storedValue === (int)$submittedValue;
7756
        // Null values are allowed, but currently there's a real (not NULL) value.
7757
        // Thus, ensure no NULL value was submitted and fallback to the regular behaviour.
7758
        } elseif ($storedValue !== null) {
7759
            $result = (
7760
                $submittedValue !== null
7761
                && $this->isSubmittedValueEqualToStoredValue($submittedValue, $storedValue, $storedType, false)
7762
            );
7763
        // Null values are allowed, and currently there's a NULL value.
7764
        // Thus, check whether a NULL value was submitted.
7765
        } else {
7766
            $result = ($submittedValue === null);
7767
        }
7768
7769
        return $result;
7770
    }
7771
7772
    /**
7773
     * Converts a HTML entity (like &#123;) to the character '123'
7774
     *
7775
     * @param string $input Input string
7776
     * @return string Output string
7777
     * @internal should only be used from within DataHandler
7778
     */
7779
    public function convNumEntityToByteValue($input)
7780
    {
7781
        $token = md5(microtime());
7782
        $parts = explode($token, (string)preg_replace('/(&#([0-9]+);)/', $token . '\\2' . $token, $input));
7783
        foreach ($parts as $k => $v) {
7784
            if ($k % 2) {
7785
                $v = (int)$v;
7786
                // Just to make sure that control bytes are not converted.
7787
                if ($v > 32) {
7788
                    $parts[$k] = chr($v);
7789
                }
7790
            }
7791
        }
7792
        return implode('', $parts);
7793
    }
7794
7795
    /**
7796
     * Disables the delete clause for fetching records.
7797
     * In general only undeleted records will be used. If the delete
7798
     * clause is disabled, also deleted records are taken into account.
7799
     */
7800
    public function disableDeleteClause()
7801
    {
7802
        $this->disableDeleteClause = true;
7803
    }
7804
7805
    /**
7806
     * Returns delete-clause for the $table
7807
     *
7808
     * @param string $table Table name
7809
     * @return string Delete clause
7810
     * @internal should only be used from within DataHandler
7811
     */
7812
    public function deleteClause($table)
7813
    {
7814
        // Returns the proper delete-clause if any for a table from TCA
7815
        if (!$this->disableDeleteClause && $GLOBALS['TCA'][$table]['ctrl']['delete']) {
7816
            return ' AND ' . $table . '.' . $GLOBALS['TCA'][$table]['ctrl']['delete'] . '=0';
7817
        }
7818
        return '';
7819
    }
7820
7821
    /**
7822
     * Add delete restriction if not disabled
7823
     *
7824
     * @param QueryRestrictionContainerInterface $restrictions
7825
     */
7826
    protected function addDeleteRestriction(QueryRestrictionContainerInterface $restrictions)
7827
    {
7828
        if (!$this->disableDeleteClause) {
7829
            $restrictions->add(GeneralUtility::makeInstance(DeletedRestriction::class));
7830
        }
7831
    }
7832
7833
    /**
7834
     * Gets UID of parent record. If record is deleted it will be looked up in
7835
     * an array built before the record was deleted
7836
     *
7837
     * @param string $table Table where record lives/lived
7838
     * @param int $uid Record UID
7839
     * @return int[] Parent UIDs
7840
     */
7841
    protected function getOriginalParentOfRecord($table, $uid)
7842
    {
7843
        if (isset(self::$recordPidsForDeletedRecords[$table][$uid])) {
7844
            return self::$recordPidsForDeletedRecords[$table][$uid];
7845
        }
7846
        [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
7847
        return [$parentUid];
7848
    }
7849
7850
    /**
7851
     * Extract entries from TSconfig for a specific table. This will merge specific and default configuration together.
7852
     *
7853
     * @param string $table Table name
7854
     * @param array $TSconfig TSconfig for page
7855
     * @return array TSconfig merged
7856
     * @internal should only be used from within DataHandler
7857
     */
7858
    public function getTableEntries($table, $TSconfig)
7859
    {
7860
        $tA = is_array($TSconfig['table.'][$table . '.'] ?? false) ? $TSconfig['table.'][$table . '.'] : [];
7861
        $dA = is_array($TSconfig['default.'] ?? false) ? $TSconfig['default.'] : [];
7862
        ArrayUtility::mergeRecursiveWithOverrule($dA, $tA);
7863
        return $dA;
7864
    }
7865
7866
    /**
7867
     * Returns the pid of a record from $table with $uid
7868
     *
7869
     * @param string $table Table name
7870
     * @param int $uid Record uid
7871
     * @return int|false PID value (unless the record did not exist in which case FALSE is returned)
7872
     * @internal should only be used from within DataHandler
7873
     */
7874
    public function getPID($table, $uid)
7875
    {
7876
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7877
        $queryBuilder->getRestrictions()
7878
            ->removeAll();
7879
        $queryBuilder->select('pid')
7880
            ->from($table)
7881
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)));
7882
        if ($row = $queryBuilder->execute()->fetch()) {
7883
            return $row['pid'];
7884
        }
7885
        return false;
7886
    }
7887
7888
    /**
7889
     * Executing dbAnalysisStore
7890
     * This will save MM relations for new records but is executed after records are created because we need to know the ID of them
7891
     * @internal should only be used from within DataHandler
7892
     */
7893
    public function dbAnalysisStoreExec()
7894
    {
7895
        foreach ($this->dbAnalysisStore as $action) {
7896
            $id = BackendUtility::wsMapId($action[4], MathUtility::canBeInterpretedAsInteger($action[2]) ? $action[2] : $this->substNEWwithIDs[$action[2]]);
7897
            if ($id) {
7898
                $action[0]->writeMM($action[1], $id, $action[3]);
7899
            }
7900
        }
7901
    }
7902
7903
    /**
7904
     * Returns array, $CPtable, of pages under the $pid going down to $counter levels.
7905
     * Selecting ONLY pages which the user has read-access to!
7906
     *
7907
     * @param array $CPtable Accumulation of page uid=>pid pairs in branch of $pid
7908
     * @param int $pid Page ID for which to find subpages
7909
     * @param int $counter Number of levels to go down.
7910
     * @param int $rootID ID of root point for new copied branch: The idea seems to be that a copy is not made of the already new page!
7911
     * @return array Return array.
7912
     * @internal should only be used from within DataHandler
7913
     */
7914
    public function int_pageTreeInfo($CPtable, $pid, $counter, $rootID)
7915
    {
7916
        if ($counter) {
7917
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
7918
            $restrictions = $queryBuilder->getRestrictions()->removeAll();
7919
            $this->addDeleteRestriction($restrictions);
7920
            $queryBuilder
7921
                ->select('uid')
7922
                ->from('pages')
7923
                ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)))
7924
                ->orderBy('sorting', 'DESC');
7925
            if (!$this->admin) {
7926
                $queryBuilder->andWhere($this->BE_USER->getPagePermsClause(Permission::PAGE_SHOW));
7927
            }
7928
            if ((int)$this->BE_USER->workspace === 0) {
7929
                $queryBuilder->andWhere(
7930
                    $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
7931
                );
7932
            } else {
7933
                $queryBuilder->andWhere($queryBuilder->expr()->in(
7934
                    't3ver_wsid',
7935
                    $queryBuilder->createNamedParameter([0, $this->BE_USER->workspace], Connection::PARAM_INT_ARRAY)
7936
                ));
7937
            }
7938
            $result = $queryBuilder->execute();
7939
7940
            $pages = [];
7941
            while ($row = $result->fetch()) {
7942
                $pages[$row['uid']] = $row;
7943
            }
7944
7945
            // Resolve placeholders of workspace versions
7946
            if (!empty($pages) && (int)$this->BE_USER->workspace !== 0) {
7947
                $pages = array_reverse(
7948
                    $this->resolveVersionedRecords(
7949
                        'pages',
7950
                        'uid',
7951
                        'sorting',
7952
                        array_keys($pages)
7953
                    ),
7954
                    true
7955
                );
7956
            }
7957
7958
            foreach ($pages as $page) {
7959
                if ($page['uid'] != $rootID) {
7960
                    $CPtable[$page['uid']] = $pid;
7961
                    // If the uid is NOT the rootID of the copyaction and if we are supposed to walk further down
7962
                    if ($counter - 1) {
7963
                        $CPtable = $this->int_pageTreeInfo($CPtable, $page['uid'], $counter - 1, $rootID);
7964
                    }
7965
                }
7966
            }
7967
        }
7968
        return $CPtable;
7969
    }
7970
7971
    /**
7972
     * List of all tables (those administrators has access to = array_keys of $GLOBALS['TCA'])
7973
     *
7974
     * @return array Array of all TCA table names
7975
     * @internal should only be used from within DataHandler
7976
     */
7977
    public function compileAdminTables()
7978
    {
7979
        return array_keys($GLOBALS['TCA']);
7980
    }
7981
7982
    /**
7983
     * Checks if any uniqueInPid eval input fields are in the record and if so, they are re-written to be correct.
7984
     *
7985
     * @param string $table Table name
7986
     * @param int $uid Record UID
7987
     * @internal should only be used from within DataHandler
7988
     */
7989
    public function fixUniqueInPid($table, $uid)
7990
    {
7991
        if (empty($GLOBALS['TCA'][$table])) {
7992
            return;
7993
        }
7994
7995
        $curData = $this->recordInfo($table, $uid, '*');
7996
        $newData = [];
7997
        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $conf) {
7998
            if ($conf['config']['type'] === 'input' && (string)$curData[$field] !== '') {
7999
                $evalCodesArray = GeneralUtility::trimExplode(',', $conf['config']['eval'] ?? '', true);
8000
                if (in_array('uniqueInPid', $evalCodesArray, true)) {
8001
                    $newV = $this->getUnique($table, $field, $curData[$field], $uid, $curData['pid']);
8002
                    if ((string)$newV !== (string)$curData[$field]) {
8003
                        $newData[$field] = $newV;
8004
                    }
8005
                }
8006
            }
8007
        }
8008
        // IF there are changed fields, then update the database
8009
        if (!empty($newData)) {
8010
            $this->updateDB($table, $uid, $newData);
8011
        }
8012
    }
8013
8014
    /**
8015
     * Checks if any uniqueInSite eval fields are in the record and if so, they are re-written to be correct.
8016
     *
8017
     * @param string $table Table name
8018
     * @param int $uid Record UID
8019
     * @return bool whether the record had to be fixed or not
8020
     */
8021
    protected function fixUniqueInSite(string $table, int $uid): bool
8022
    {
8023
        $curData = $this->recordInfo($table, $uid, '*');
8024
        $workspaceId = $this->BE_USER->workspace;
8025
        $newData = [];
8026
        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $conf) {
8027
            if ($conf['config']['type'] === 'slug' && (string)$curData[$field] !== '') {
8028
                $evalCodesArray = GeneralUtility::trimExplode(',', $conf['config']['eval'], true);
8029
                if (in_array('uniqueInSite', $evalCodesArray, true)) {
8030
                    $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $conf['config'], $workspaceId);
8031
                    $state = RecordStateFactory::forName($table)->fromArray($curData);
0 ignored issues
show
Bug introduced by
It seems like $curData can also be of type null; however, parameter $data of TYPO3\CMS\Core\DataHandl...ateFactory::fromArray() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

8031
                    $state = RecordStateFactory::forName($table)->fromArray(/** @scrutinizer ignore-type */ $curData);
Loading history...
8032
                    $newValue = $helper->buildSlugForUniqueInSite($curData[$field], $state);
8033
                    if ((string)$newValue !== (string)$curData[$field]) {
8034
                        $newData[$field] = $newValue;
8035
                    }
8036
                }
8037
            }
8038
        }
8039
        // IF there are changed fields, then update the database
8040
        if (!empty($newData)) {
8041
            $this->updateDB($table, $uid, $newData);
8042
            return true;
8043
        }
8044
        return false;
8045
    }
8046
8047
    /**
8048
     * Check if there are subpages that need an adoption as well
8049
     * @param int $pageId
8050
     */
8051
    protected function fixUniqueInSiteForSubpages(int $pageId)
8052
    {
8053
        // Get ALL subpages to update - read-permissions are respected
8054
        $subPages = $this->int_pageTreeInfo([], $pageId, 99, $pageId);
8055
        // Now fix uniqueInSite for subpages
8056
        foreach ($subPages as $thePageUid => $thePagePid) {
8057
            $recordWasModified = $this->fixUniqueInSite('pages', $thePageUid);
8058
            if ($recordWasModified) {
8059
                // @todo: Add logging and history - but how? we don't know the data that was in the system before
8060
            }
8061
        }
8062
    }
8063
8064
    /**
8065
     * When er record is copied you can specify fields from the previous record which should be copied into the new one
8066
     * This function is also called with new elements. But then $update must be set to zero and $newData containing the data array. In that case data in the incoming array is NOT overridden. (250202)
8067
     *
8068
     * @param string $table Table name
8069
     * @param int $uid Record UID
8070
     * @param int $prevUid UID of previous record
8071
     * @param bool $update If set, updates the record
8072
     * @param array $newData Input array. If fields are already specified AND $update is not set, values are not set in output array.
8073
     * @return array Output array (For when the copying operation needs to get the information instead of updating the info)
8074
     * @internal should only be used from within DataHandler
8075
     */
8076
    public function fixCopyAfterDuplFields($table, $uid, $prevUid, $update, $newData = [])
8077
    {
8078
        if ($GLOBALS['TCA'][$table]['ctrl']['copyAfterDuplFields'] ?? false) {
8079
            $prevData = $this->recordInfo($table, $prevUid, '*');
8080
            $theFields = GeneralUtility::trimExplode(',', $GLOBALS['TCA'][$table]['ctrl']['copyAfterDuplFields'], true);
8081
            foreach ($theFields as $field) {
8082
                if ($GLOBALS['TCA'][$table]['columns'][$field] && ($update || !isset($newData[$field]))) {
8083
                    $newData[$field] = $prevData[$field];
8084
                }
8085
            }
8086
            if ($update && !empty($newData)) {
8087
                $this->updateDB($table, $uid, $newData);
8088
            }
8089
        }
8090
        return $newData;
8091
    }
8092
8093
    /**
8094
     * Casts a reference value. In case MM relations or foreign_field
8095
     * references are used. All other configurations, as well as
8096
     * foreign_table(!) could be stored as comma-separated-values
8097
     * as well. Since the system is not able to determine the default
8098
     * value automatically then, the TCA default value is used if
8099
     * it has been defined.
8100
     *
8101
     * @param int|string $value The value to be casted (e.g. '', '0', '1,2,3')
8102
     * @param array $configuration The TCA configuration of the accordant field
8103
     * @return int|string
8104
     */
8105
    protected function castReferenceValue($value, array $configuration)
8106
    {
8107
        if ((string)$value !== '') {
8108
            return $value;
8109
        }
8110
8111
        if (!empty($configuration['MM']) || !empty($configuration['foreign_field'])) {
8112
            return 0;
8113
        }
8114
8115
        if (array_key_exists('default', $configuration)) {
8116
            return $configuration['default'];
8117
        }
8118
8119
        return $value;
8120
    }
8121
8122
    /**
8123
     * Returns TRUE if the TCA/columns field type is a DB reference field
8124
     *
8125
     * @param array $conf Config array for TCA/columns field
8126
     * @return bool TRUE if DB reference field (group/db or select with foreign-table)
8127
     * @internal should only be used from within DataHandler
8128
     */
8129
    public function isReferenceField($conf)
8130
    {
8131
        return isset($conf['type'], $conf['internal_type']) && $conf['type'] === 'group' && $conf['internal_type'] === 'db'
8132
            || isset($conf['type'], $conf['foreign_table']) && $conf['type'] === 'select' && $conf['foreign_table'];
8133
    }
8134
8135
    /**
8136
     * Returns the subtype as a string of an inline field.
8137
     * If it's not an inline field at all, it returns FALSE.
8138
     *
8139
     * @param array $conf Config array for TCA/columns field
8140
     * @return string|bool string Inline subtype (field|mm|list), boolean: FALSE
8141
     * @internal should only be used from within DataHandler
8142
     */
8143
    public function getInlineFieldType($conf)
8144
    {
8145
        if (empty($conf['type']) || $conf['type'] !== 'inline' || empty($conf['foreign_table'])) {
8146
            return false;
8147
        }
8148
        if ($conf['foreign_field'] ?? false) {
8149
            // The reference to the parent is stored in a pointer field in the child record
8150
            return 'field';
8151
        }
8152
        if ($conf['MM'] ?? false) {
8153
            // Regular MM intermediate table is used to store data
8154
            return 'mm';
8155
        }
8156
        // An item list (separated by comma) is stored (like select type is doing)
8157
        return 'list';
8158
    }
8159
8160
    /**
8161
     * Get modified header for a copied record
8162
     *
8163
     * @param string $table Table name
8164
     * @param int $pid PID value in which other records to test might be
8165
     * @param string $field Field name to get header value for.
8166
     * @param string $value Current field value
8167
     * @param int $count Counter (number of recursions)
8168
     * @param string $prevTitle Previous title we checked for (in previous recursion)
8169
     * @return string The field value, possibly appended with a "copy label
8170
     * @internal should only be used from within DataHandler
8171
     */
8172
    public function getCopyHeader($table, $pid, $field, $value, $count, $prevTitle = '')
8173
    {
8174
        // Set title value to check for:
8175
        $checkTitle = $value;
8176
        if ($count > 0) {
8177
            $checkTitle = $value . rtrim(' ' . sprintf($this->prependLabel($table), $count));
8178
        }
8179
        // Do check:
8180
        if ($prevTitle != $checkTitle || $count < 100) {
8181
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8182
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
8183
            $rowCount = $queryBuilder
8184
                ->count('uid')
8185
                ->from($table)
8186
                ->where(
8187
                    $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)),
8188
                    $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($checkTitle, \PDO::PARAM_STR))
8189
                )
8190
                ->execute()
8191
                ->fetchColumn(0);
8192
            if ($rowCount) {
8193
                return $this->getCopyHeader($table, $pid, $field, $value, $count + 1, $checkTitle);
8194
            }
8195
        }
8196
        // Default is to just return the current input title if no other was returned before:
8197
        return $checkTitle;
8198
    }
8199
8200
    /**
8201
     * Return "copy" label for a table. Although the name is "prepend" it actually APPENDs the label (after ...)
8202
     *
8203
     * @param string $table Table name
8204
     * @return string Label to append, containing "%s" for the number
8205
     * @see getCopyHeader()
8206
     * @internal should only be used from within DataHandler
8207
     */
8208
    public function prependLabel($table)
8209
    {
8210
        return $this->getLanguageService()->sL($GLOBALS['TCA'][$table]['ctrl']['prependAtCopy']);
8211
    }
8212
8213
    /**
8214
     * Get the final pid based on $table and $pid ($destPid type... pos/neg)
8215
     *
8216
     * @param string $table Table name
8217
     * @param int $pid "Destination pid" : If the value is >= 0 it's just returned directly (through (int)though) but if the value is <0 then the method looks up the record with the uid equal to abs($pid) (positive number) and returns the PID of that record! The idea is that negative numbers point to the record AFTER WHICH the position is supposed to be!
8218
     * @return int
8219
     * @internal should only be used from within DataHandler
8220
     */
8221
    public function resolvePid($table, $pid)
8222
    {
8223
        $pid = (int)$pid;
8224
        if ($pid < 0) {
8225
            $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8226
            $query->getRestrictions()
8227
                ->removeAll();
8228
            $row = $query
8229
                ->select('pid')
8230
                ->from($table)
8231
                ->where($query->expr()->eq('uid', $query->createNamedParameter(abs($pid), \PDO::PARAM_INT)))
8232
                ->execute()
8233
                ->fetch();
8234
            $pid = (int)$row['pid'];
8235
        }
8236
        return $pid;
8237
    }
8238
8239
    /**
8240
     * Removes the prependAtCopy prefix on values
8241
     *
8242
     * @param string $table Table name
8243
     * @param string $value The value to fix
8244
     * @return string Clean name
8245
     * @internal should only be used from within DataHandler
8246
     */
8247
    public function clearPrefixFromValue($table, $value)
8248
    {
8249
        $regex = '/\s' . sprintf(preg_quote($this->prependLabel($table)), '[0-9]*') . '$/';
8250
        return @preg_replace($regex, '', $value);
8251
    }
8252
8253
    /**
8254
     * Check if there are records from tables on the pages to be deleted which the current user is not allowed to
8255
     *
8256
     * @param int[] $pageIds IDs of pages which should be checked
8257
     * @return string[]|null Return null, if permission granted, otherwise an array with the tables that are not allowed to be deleted
8258
     * @see canDeletePage()
8259
     */
8260
    protected function checkForRecordsFromDisallowedTables(array $pageIds): ?array
8261
    {
8262
        if ($this->admin) {
8263
            return null;
8264
        }
8265
8266
        $disallowedTables = [];
8267
        if (!empty($pageIds)) {
8268
            $tableNames = $this->compileAdminTables();
8269
            foreach ($tableNames as $table) {
8270
                $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8271
                $query->getRestrictions()
8272
                    ->removeAll()
8273
                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8274
                $count = $query->count('uid')
8275
                    ->from($table)
8276
                    ->where($query->expr()->in(
8277
                        'pid',
8278
                        $query->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY)
8279
                    ))
8280
                    ->execute()
8281
                    ->fetchColumn(0);
8282
                if ($count && ($this->tableReadOnly($table) || !$this->checkModifyAccessList($table))) {
8283
                    $disallowedTables[] = $table;
8284
                }
8285
            }
8286
        }
8287
        return !empty($disallowedTables) ? $disallowedTables : null;
8288
    }
8289
8290
    /**
8291
     * Determine if a record was copied or if a record is the result of a copy action.
8292
     *
8293
     * @param string $table The tablename of the record
8294
     * @param int $uid The uid of the record
8295
     * @return bool Returns TRUE if the record is copied or is the result of a copy action
8296
     * @internal should only be used from within DataHandler
8297
     */
8298
    public function isRecordCopied($table, $uid)
8299
    {
8300
        // If the record was copied:
8301
        if (isset($this->copyMappingArray[$table][$uid])) {
8302
            return true;
8303
        }
8304
        if (isset($this->copyMappingArray[$table]) && in_array($uid, array_values($this->copyMappingArray[$table]))) {
8305
            return true;
8306
        }
8307
        return false;
8308
    }
8309
8310
    /******************************
8311
     *
8312
     * Clearing cache
8313
     *
8314
     ******************************/
8315
8316
    /**
8317
     * Clearing the cache based on a page being updated
8318
     * If the $table is 'pages' then cache is cleared for all pages on the same level (and subsequent?)
8319
     * Else just clear the cache for the parent page of the record.
8320
     *
8321
     * @param string $table Table name of record that was just updated.
8322
     * @param int $uid UID of updated / inserted record
8323
     * @param int $pid REAL PID of page of a deleted/moved record to get TSconfig in ClearCache.
8324
     * @internal This method is not meant to be called directly but only from the core itself or from hooks
8325
     */
8326
    public function registerRecordIdForPageCacheClearing($table, $uid, $pid = null)
8327
    {
8328
        if (!is_array(static::$recordsToClearCacheFor[$table] ?? false)) {
8329
            static::$recordsToClearCacheFor[$table] = [];
8330
        }
8331
        static::$recordsToClearCacheFor[$table][] = (int)$uid;
8332
        if ($pid !== null) {
8333
            if (!isset(static::$recordPidsForDeletedRecords[$table]) || !is_array(static::$recordPidsForDeletedRecords[$table])) {
8334
                static::$recordPidsForDeletedRecords[$table] = [];
8335
            }
8336
            static::$recordPidsForDeletedRecords[$table][$uid][] = (int)$pid;
8337
        }
8338
    }
8339
8340
    /**
8341
     * Do the actual clear cache
8342
     */
8343
    protected function processClearCacheQueue()
8344
    {
8345
        $tagsToClear = [];
8346
        $clearCacheCommands = [];
8347
8348
        foreach (static::$recordsToClearCacheFor as $table => $uids) {
8349
            foreach (array_unique($uids) as $uid) {
8350
                if (!isset($GLOBALS['TCA'][$table]) || $uid <= 0) {
8351
                    return;
8352
                }
8353
                // For move commands we may get more then 1 parent.
8354
                $pageUids = $this->getOriginalParentOfRecord($table, $uid);
8355
                foreach ($pageUids as $originalParent) {
8356
                    [$tagsToClearFromPrepare, $clearCacheCommandsFromPrepare]
8357
                        = $this->prepareCacheFlush($table, $uid, $originalParent);
8358
                    $tagsToClear = array_merge($tagsToClear, $tagsToClearFromPrepare);
8359
                    $clearCacheCommands = array_merge($clearCacheCommands, $clearCacheCommandsFromPrepare);
8360
                }
8361
            }
8362
        }
8363
8364
        /** @var CacheManager $cacheManager */
8365
        $cacheManager = $this->getCacheManager();
8366
        $cacheManager->flushCachesInGroupByTags('pages', array_keys($tagsToClear));
8367
8368
        // Filter duplicate cache commands from cacheQueue
8369
        $clearCacheCommands = array_unique($clearCacheCommands);
8370
        // Execute collected clear cache commands from page TSConfig
8371
        foreach ($clearCacheCommands as $command) {
8372
            $this->clear_cacheCmd($command);
8373
        }
8374
8375
        // Reset the cache clearing array
8376
        static::$recordsToClearCacheFor = [];
8377
8378
        // Reset the original pid array
8379
        static::$recordPidsForDeletedRecords = [];
8380
    }
8381
8382
    /**
8383
     * Prepare the cache clearing
8384
     *
8385
     * @param string $table Table name of record that needs to be cleared
8386
     * @param int $uid UID of record for which the cache needs to be cleared
8387
     * @param int $pid Original pid of the page of the record which the cache needs to be cleared
8388
     * @return array Array with tagsToClear and clearCacheCommands
8389
     * @internal This function is internal only it may be changed/removed also in minor version numbers.
8390
     */
8391
    protected function prepareCacheFlush($table, $uid, $pid)
8392
    {
8393
        $tagsToClear = [];
8394
        $clearCacheCommands = [];
8395
        $pageUid = 0;
8396
        // Get Page TSconfig relevant:
8397
        $TSConfig = BackendUtility::getPagesTSconfig($pid)['TCEMAIN.'] ?? [];
8398
        if (empty($TSConfig['clearCache_disable']) && $this->BE_USER->workspace === 0) {
8399
            $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
8400
            // If table is "pages":
8401
            $pageIdsThatNeedCacheFlush = [];
8402
            if ($table === 'pages') {
8403
                // Find out if the record is a get the original page
8404
                $pageUid = $this->getDefaultLanguagePageId($uid);
8405
8406
                // Builds list of pages on the SAME level as this page (siblings)
8407
                $queryBuilder = $connectionPool->getQueryBuilderForTable('pages');
8408
                $queryBuilder->getRestrictions()
8409
                    ->removeAll()
8410
                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8411
                $siblings = $queryBuilder
8412
                    ->select('A.pid AS pid', 'B.uid AS uid')
8413
                    ->from('pages', 'A')
8414
                    ->from('pages', 'B')
8415
                    ->where(
8416
                        $queryBuilder->expr()->eq('A.uid', $queryBuilder->createNamedParameter($pageUid, \PDO::PARAM_INT)),
8417
                        $queryBuilder->expr()->eq('B.pid', $queryBuilder->quoteIdentifier('A.pid')),
8418
                        $queryBuilder->expr()->gte('A.pid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
8419
                    )
8420
                    ->execute();
8421
8422
                $parentPageId = 0;
8423
                while ($row_tmp = $siblings->fetch()) {
8424
                    $pageIdsThatNeedCacheFlush[] = (int)$row_tmp['uid'];
8425
                    $parentPageId = (int)$row_tmp['pid'];
8426
                    // Add children as well:
8427
                    if ($TSConfig['clearCache_pageSiblingChildren'] ?? false) {
8428
                        $siblingChildrenQuery = $connectionPool->getQueryBuilderForTable('pages');
8429
                        $siblingChildrenQuery->getRestrictions()
8430
                            ->removeAll()
8431
                            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8432
                        $siblingChildren = $siblingChildrenQuery
8433
                            ->select('uid')
8434
                            ->from('pages')
8435
                            ->where($siblingChildrenQuery->expr()->eq(
8436
                                'pid',
8437
                                $siblingChildrenQuery->createNamedParameter($row_tmp['uid'], \PDO::PARAM_INT)
8438
                            ))
8439
                            ->execute();
8440
                        while ($row_tmp2 = $siblingChildren->fetch()) {
8441
                            $pageIdsThatNeedCacheFlush[] = (int)$row_tmp2['uid'];
8442
                        }
8443
                    }
8444
                }
8445
                // Finally, add the parent page as well when clearing a specific page
8446
                if ($parentPageId > 0) {
8447
                    $pageIdsThatNeedCacheFlush[] = $parentPageId;
8448
                }
8449
                // Add grand-parent as well if configured
8450
                if ($TSConfig['clearCache_pageGrandParent'] ?? false) {
8451
                    $parentQuery = $connectionPool->getQueryBuilderForTable('pages');
8452
                    $parentQuery->getRestrictions()
8453
                        ->removeAll()
8454
                        ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8455
                    $row_tmp = $parentQuery
8456
                        ->select('pid')
8457
                        ->from('pages')
8458
                        ->where($parentQuery->expr()->eq(
8459
                            'uid',
8460
                            $parentQuery->createNamedParameter($parentPageId, \PDO::PARAM_INT)
8461
                        ))
8462
                        ->execute()
8463
                        ->fetch();
8464
                    if (!empty($row_tmp)) {
8465
                        $pageIdsThatNeedCacheFlush[] = (int)$row_tmp['pid'];
8466
                    }
8467
                }
8468
            } else {
8469
                // For other tables than "pages", delete cache for the records "parent page".
8470
                $pageIdsThatNeedCacheFlush[] = $pageUid = (int)$this->getPID($table, $uid);
8471
                // Add the parent page as well
8472
                if ($TSConfig['clearCache_pageGrandParent'] ?? false) {
8473
                    $parentQuery = $connectionPool->getQueryBuilderForTable('pages');
8474
                    $parentQuery->getRestrictions()
8475
                        ->removeAll()
8476
                        ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8477
                    $parentPageRecord = $parentQuery
8478
                        ->select('pid')
8479
                        ->from('pages')
8480
                        ->where($parentQuery->expr()->eq(
8481
                            'uid',
8482
                            $parentQuery->createNamedParameter($pageUid, \PDO::PARAM_INT)
8483
                        ))
8484
                        ->execute()
8485
                        ->fetch();
8486
                    if (!empty($parentPageRecord)) {
8487
                        $pageIdsThatNeedCacheFlush[] = (int)$parentPageRecord['pid'];
8488
                    }
8489
                }
8490
            }
8491
            // Call pre-processing function for clearing of cache for page ids:
8492
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearPageCacheEval'] ?? [] as $funcName) {
8493
                $_params = ['pageIdArray' => &$pageIdsThatNeedCacheFlush, 'table' => $table, 'uid' => $uid, 'functionID' => 'clear_cache()'];
8494
                // Returns the array of ids to clear, FALSE if nothing should be cleared! Never an empty array!
8495
                GeneralUtility::callUserFunction($funcName, $_params, $this);
8496
            }
8497
            // Delete cache for selected pages:
8498
            foreach ($pageIdsThatNeedCacheFlush as $pageId) {
8499
                $tagsToClear['pageId_' . $pageId] = true;
8500
            }
8501
            // Queue delete cache for current table and record
8502
            $tagsToClear[$table] = true;
8503
            $tagsToClear[$table . '_' . $uid] = true;
8504
        }
8505
        // Clear cache for pages entered in TSconfig:
8506
        if (!empty($TSConfig['clearCacheCmd'])) {
8507
            $commands = GeneralUtility::trimExplode(',', $TSConfig['clearCacheCmd'], true);
8508
            $clearCacheCommands = array_unique($commands);
8509
        }
8510
        // Call post processing function for clear-cache:
8511
        $_params = ['table' => $table, 'uid' => $uid, 'uid_page' => $pageUid, 'TSConfig' => $TSConfig, 'tags' => $tagsToClear];
8512
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] ?? [] as $_funcRef) {
8513
            GeneralUtility::callUserFunction($_funcRef, $_params, $this);
8514
        }
8515
        return [
8516
            $tagsToClear,
8517
            $clearCacheCommands
8518
        ];
8519
    }
8520
8521
    /**
8522
     * Clears the cache based on the command $cacheCmd.
8523
     *
8524
     * $cacheCmd='pages'
8525
     * Clears cache for all pages and page-based caches inside the cache manager.
8526
     * Requires admin-flag to be set for BE_USER.
8527
     *
8528
     * $cacheCmd='all'
8529
     * Clears all cache_tables. This is necessary if templates are updated.
8530
     * Requires admin-flag to be set for BE_USER.
8531
     *
8532
     * The following cache_* are intentionally not cleared by 'all'
8533
     *
8534
     * - imagesizes:	Clearing this table would cause a lot of unneeded
8535
     * Imagemagick calls because the size information has
8536
     * to be fetched again after clearing.
8537
     * - all caches inside the cache manager that are inside the group "system"
8538
     * - they are only needed to build up the core system and templates.
8539
     *   If the group of system caches needs to be deleted explicitly, use
8540
     *   flushCachesInGroup('system') of CacheManager directly.
8541
     *
8542
     * $cacheCmd=[integer]
8543
     * Clears cache for the page pointed to by $cacheCmd (an integer).
8544
     *
8545
     * $cacheCmd='cacheTag:[string]'
8546
     * Flush page and pagesection cache by given tag
8547
     *
8548
     * $cacheCmd='cacheId:[string]'
8549
     * Removes cache identifier from page and page section cache
8550
     *
8551
     * Can call a list of post processing functions as defined in
8552
     * $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']
8553
     * (numeric array with values being the function references, called by
8554
     * GeneralUtility::callUserFunction()).
8555
     *
8556
     *
8557
     * @param string $cacheCmd The cache command, see above description
8558
     */
8559
    public function clear_cacheCmd($cacheCmd)
8560
    {
8561
        if (is_object($this->BE_USER)) {
8562
            $this->BE_USER->writeLog(SystemLogType::CACHE, SystemLogCacheAction::CLEAR, SystemLogErrorClassification::MESSAGE, 0, 'User %s has cleared the cache (cacheCmd=%s)', [$this->BE_USER->user['username'], $cacheCmd]);
8563
        }
8564
        $userTsConfig = $this->BE_USER->getTSConfig();
8565
        switch (strtolower($cacheCmd)) {
8566
            case 'pages':
8567
                if ($this->admin || ($userTsConfig['options.']['clearCache.']['pages'] ?? false)) {
8568
                    $this->getCacheManager()->flushCachesInGroup('pages');
8569
                }
8570
                break;
8571
            case 'all':
8572
                // allow to clear all caches if the TS config option is enabled or the option is not explicitly
8573
                // disabled for admins (which could clear all caches by default). The latter option is useful
8574
                // for big production sites where it should be possible to restrict the cache clearing for some admins.
8575
                if (($userTsConfig['options.']['clearCache.']['all'] ?? false)
8576
                    || ($this->admin && (bool)($userTsConfig['options.']['clearCache.']['all'] ?? true))
8577
                ) {
8578
                    $this->getCacheManager()->flushCaches();
8579
                    GeneralUtility::makeInstance(ConnectionPool::class)
8580
                        ->getConnectionForTable('cache_treelist')
8581
                        ->truncate('cache_treelist');
8582
8583
                    // Delete Opcode Cache
8584
                    GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive();
8585
                }
8586
                break;
8587
        }
8588
8589
        $tagsToFlush = [];
8590
        // Clear cache for a page ID!
8591
        if (MathUtility::canBeInterpretedAsInteger($cacheCmd)) {
8592
            $list_cache = [$cacheCmd];
8593
            // Call pre-processing function for clearing of cache for page ids:
8594
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearPageCacheEval'] ?? [] as $funcName) {
8595
                $_params = ['pageIdArray' => &$list_cache, 'cacheCmd' => $cacheCmd, 'functionID' => 'clear_cacheCmd()'];
8596
                // Returns the array of ids to clear, FALSE if nothing should be cleared! Never an empty array!
8597
                GeneralUtility::callUserFunction($funcName, $_params, $this);
8598
            }
8599
            // Delete cache for selected pages:
8600
            if (is_array($list_cache)) {
0 ignored issues
show
introduced by
The condition is_array($list_cache) is always true.
Loading history...
8601
                foreach ($list_cache as $pageId) {
8602
                    $tagsToFlush[] = 'pageId_' . (int)$pageId;
8603
                }
8604
            }
8605
        }
8606
        // flush cache by tag
8607
        if (GeneralUtility::isFirstPartOfStr(strtolower($cacheCmd), 'cachetag:')) {
8608
            $cacheTag = substr($cacheCmd, 9);
8609
            $tagsToFlush[] = $cacheTag;
8610
        }
8611
        // process caching framework operations
8612
        if (!empty($tagsToFlush)) {
8613
            $this->getCacheManager()->flushCachesInGroupByTags('pages', $tagsToFlush);
0 ignored issues
show
Bug introduced by
The method getCacheManager() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

8613
            $this->/** @scrutinizer ignore-call */ 
8614
                   getCacheManager()->flushCachesInGroupByTags('pages', $tagsToFlush);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
8614
        }
8615
8616
        // Call post processing function for clear-cache:
8617
        $_params = ['cacheCmd' => strtolower($cacheCmd), 'tags' => $tagsToFlush];
8618
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] ?? [] as $_funcRef) {
8619
            GeneralUtility::callUserFunction($_funcRef, $_params, $this);
8620
        }
8621
    }
8622
8623
    /*****************************
8624
     *
8625
     * Logging
8626
     *
8627
     *****************************/
8628
    /**
8629
     * Logging actions from DataHandler
8630
     *
8631
     * @param string $table Table name the log entry is concerned with. Blank if NA
8632
     * @param int $recuid Record UID. Zero if NA
8633
     * @param int $action Action number: 0=No category, 1=new record, 2=update record, 3= delete record, 4= move record, 5= Check/evaluate
8634
     * @param int|string $recpid Normally 0 (zero). If set, it indicates that this log-entry is used to notify the backend of a record which is moved to another location
8635
     * @param int $error The severity: 0 = message, 1 = error, 2 = System Error, 3 = security notice (admin), 4 warning
8636
     * @param string $details Default error message in english
8637
     * @param int $details_nr This number is unique for every combination of $type and $action. This is the error-message number, which can later be used to translate error messages. 0 if not categorized, -1 if temporary
8638
     * @param array $data Array with special information that may go into $details by '%s' marks / sprintf() when the log is shown
8639
     * @param int $event_pid The page_uid (pid) where the event occurred. Used to select log-content for specific pages.
8640
     * @param string $NEWid NEW id for new records
8641
     * @return int Log entry UID (0 if no log entry was written or logging is disabled)
8642
     * @see \TYPO3\CMS\Core\SysLog\Action\Database for all available values of argument $action
8643
     * @see \TYPO3\CMS\Core\SysLog\Error for all available values of argument $error
8644
     * @internal should only be used from within TYPO3 Core
8645
     */
8646
    public function log($table, $recuid, $action, $recpid, $error, $details, $details_nr = -1, $data = [], $event_pid = -1, $NEWid = '')
8647
    {
8648
        if (!$this->enableLogging) {
8649
            return 0;
8650
        }
8651
        // Type value for DataHandler
8652
        if (!$this->storeLogMessages) {
8653
            $details = '';
8654
        }
8655
        if ($error > 0) {
8656
            $detailMessage = $details;
8657
            if (is_array($data)) {
0 ignored issues
show
introduced by
The condition is_array($data) is always true.
Loading history...
8658
                $detailMessage = vsprintf($details, $data);
8659
            }
8660
            $this->errorLog[] = '[' . SystemLogType::DB . '.' . $action . '.' . $details_nr . ']: ' . $detailMessage;
8661
        }
8662
        return $this->BE_USER->writelog(SystemLogType::DB, $action, $error, $details_nr, $details, $data, $table, $recuid, $recpid, $event_pid, $NEWid);
8663
    }
8664
8665
    /**
8666
     * Simple logging function meant to be used when logging messages is not yet fixed.
8667
     *
8668
     * @param string $message Message string
8669
     * @param int $error Error code, see log()
8670
     * @return int Log entry UID
8671
     * @see log()
8672
     * @internal should only be used from within TYPO3 Core
8673
     */
8674
    public function newlog($message, $error = SystemLogErrorClassification::MESSAGE)
8675
    {
8676
        return $this->log('', 0, SystemLogGenericAction::UNDEFINED, 0, $error, $message, -1);
8677
    }
8678
8679
    /**
8680
     * Print log error messages from the operations of this script instance
8681
     * @internal should only be used from within TYPO3 Core
8682
     */
8683
    public function printLogErrorMessages()
8684
    {
8685
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
8686
        $queryBuilder->getRestrictions()->removeAll();
8687
        $result = $queryBuilder
8688
            ->select('*')
8689
            ->from('sys_log')
8690
            ->where(
8691
                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(1, \PDO::PARAM_INT)),
8692
                $queryBuilder->expr()->lt('action', $queryBuilder->createNamedParameter(256, \PDO::PARAM_INT)),
8693
                $queryBuilder->expr()->eq(
8694
                    'userid',
8695
                    $queryBuilder->createNamedParameter($this->BE_USER->user['uid'], \PDO::PARAM_INT)
8696
                ),
8697
                $queryBuilder->expr()->eq(
8698
                    'tstamp',
8699
                    $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
8700
                ),
8701
                $queryBuilder->expr()->neq('error', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
8702
            )
8703
            ->execute();
8704
8705
        while ($row = $result->fetch()) {
8706
            $log_data = unserialize($row['log_data']);
8707
            $msg = $row['error'] . ': ' . sprintf($row['details'], $log_data[0], $log_data[1], $log_data[2], $log_data[3], $log_data[4]);
8708
            /** @var FlashMessage $flashMessage */
8709
            $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $msg, '', $row['error'] === 4 ? FlashMessage::WARNING : FlashMessage::ERROR, true);
8710
            /** @var FlashMessageService $flashMessageService */
8711
            $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
8712
            $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier();
8713
            $defaultFlashMessageQueue->enqueue($flashMessage);
8714
        }
8715
    }
8716
8717
    /*****************************
8718
     *
8719
     * Internal (do not use outside Core!)
8720
     *
8721
     *****************************/
8722
8723
    /**
8724
     * Find out if the record is a localization. If so, get the uid of the default language page.
8725
     * Always returns the uid of the workspace live record: No explicit workspace overlay is applied.
8726
     *
8727
     * @param int $pageId Page UID, can be the default page record, or a page translation record ID
8728
     * @return int UID of the default page record in live workspace
8729
     */
8730
    protected function getDefaultLanguagePageId(int $pageId): int
8731
    {
8732
        $localizationParentFieldName = $GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField'];
8733
        $row = $this->recordInfo('pages', $pageId, $localizationParentFieldName);
8734
        $localizationParent = (int)($row[$localizationParentFieldName] ?? 0);
8735
        if ($localizationParent > 0) {
8736
            return $localizationParent;
8737
        }
8738
        return $pageId;
8739
    }
8740
8741
    /**
8742
     * Preprocesses field array based on field type. Some fields must be adjusted
8743
     * before going to database. This is done on the copy of the field array because
8744
     * original values are used in remap action later.
8745
     *
8746
     * @param string $table	Table name
8747
     * @param array $fieldArray	Field array to check
8748
     * @return array Updated field array
8749
     * @internal should only be used from within TYPO3 Core
8750
     */
8751
    public function insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray)
8752
    {
8753
        $result = $fieldArray;
8754
        foreach ($fieldArray as $field => $value) {
8755
            if (!MathUtility::canBeInterpretedAsInteger($value)
8756
                && isset($GLOBALS['TCA'][$table]['columns'][$field]['config']['type'])
8757
                && $GLOBALS['TCA'][$table]['columns'][$field]['config']['type'] === 'inline'
8758
                && ($GLOBALS['TCA'][$table]['columns'][$field]['config']['foreign_field'] ?? false)
8759
            ) {
8760
                $result[$field] = count(GeneralUtility::trimExplode(',', $value, true));
8761
            }
8762
        }
8763
        return $result;
8764
    }
8765
8766
    /**
8767
     * Determines whether a particular record has been deleted
8768
     * using DataHandler::deleteRecord() in this instance.
8769
     *
8770
     * @param string $tableName
8771
     * @param int $uid
8772
     * @return bool
8773
     * @internal should only be used from within TYPO3 Core
8774
     */
8775
    public function hasDeletedRecord($tableName, $uid)
8776
    {
8777
        return
8778
            !empty($this->deletedRecords[$tableName])
8779
            && in_array($uid, $this->deletedRecords[$tableName])
8780
        ;
8781
    }
8782
8783
    /**
8784
     * Gets the automatically versionized id of a record.
8785
     *
8786
     * @param string $table Name of the table
8787
     * @param int $id Uid of the record
8788
     * @return int|null
8789
     * @internal should only be used from within TYPO3 Core
8790
     */
8791
    public function getAutoVersionId($table, $id): ?int
8792
    {
8793
        $result = null;
8794
        if (isset($this->autoVersionIdMap[$table][$id])) {
8795
            $result = (int)trim($this->autoVersionIdMap[$table][$id]);
8796
        }
8797
        return $result;
8798
    }
8799
8800
    /**
8801
     * Overlays the automatically versionized id of a record.
8802
     *
8803
     * @param string $table Name of the table
8804
     * @param int $id Uid of the record
8805
     * @return int
8806
     */
8807
    protected function overlayAutoVersionId($table, $id)
8808
    {
8809
        $autoVersionId = $this->getAutoVersionId($table, $id);
8810
        if ($autoVersionId !== null) {
8811
            $id = $autoVersionId;
8812
        }
8813
        return $id;
8814
    }
8815
8816
    /**
8817
     * Adds new values to the remapStackChildIds array.
8818
     *
8819
     * @param array $idValues uid values
8820
     */
8821
    protected function addNewValuesToRemapStackChildIds(array $idValues)
8822
    {
8823
        foreach ($idValues as $idValue) {
8824
            if (strpos($idValue, 'NEW') === 0) {
8825
                $this->remapStackChildIds[$idValue] = true;
8826
            }
8827
        }
8828
    }
8829
8830
    /**
8831
     * Resolves versioned records for the current workspace scope.
8832
     * Delete placeholders are substituted and removed.
8833
     *
8834
     * @param string $tableName Name of the table to be processed
8835
     * @param string $fieldNames List of the field names to be fetched
8836
     * @param string $sortingField Name of the sorting field to be used
8837
     * @param array $liveIds Flat array of (live) record ids
8838
     * @return array
8839
     */
8840
    protected function resolveVersionedRecords($tableName, $fieldNames, $sortingField, array $liveIds)
8841
    {
8842
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)
8843
            ->getConnectionForTable($tableName);
8844
        $sortingStatement = !empty($sortingField)
8845
            ? [$connection->quoteIdentifier($sortingField)]
8846
            : null;
8847
        /** @var PlainDataResolver $resolver */
8848
        $resolver = GeneralUtility::makeInstance(
8849
            PlainDataResolver::class,
8850
            $tableName,
8851
            $liveIds,
8852
            $sortingStatement
8853
        );
8854
8855
        $resolver->setWorkspaceId($this->BE_USER->workspace);
8856
        $resolver->setKeepDeletePlaceholder(false);
8857
        $resolver->setKeepMovePlaceholder(false);
8858
        $resolver->setKeepLiveIds(true);
8859
        $recordIds = $resolver->get();
8860
8861
        $records = [];
8862
        foreach ($recordIds as $recordId) {
8863
            $records[$recordId] = BackendUtility::getRecord($tableName, $recordId, $fieldNames);
8864
        }
8865
8866
        return $records;
8867
    }
8868
8869
    /**
8870
     * Gets the outer most instance of \TYPO3\CMS\Core\DataHandling\DataHandler
8871
     * Since \TYPO3\CMS\Core\DataHandling\DataHandler can create nested objects of itself,
8872
     * this method helps to determine the first (= outer most) one.
8873
     *
8874
     * @return DataHandler
8875
     */
8876
    protected function getOuterMostInstance()
8877
    {
8878
        if (!isset($this->outerMostInstance)) {
8879
            $stack = array_reverse(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS));
8880
            foreach ($stack as $stackItem) {
8881
                if (isset($stackItem['object']) && $stackItem['object'] instanceof self) {
8882
                    $this->outerMostInstance = $stackItem['object'];
8883
                    break;
8884
                }
8885
            }
8886
        }
8887
        return $this->outerMostInstance;
8888
    }
8889
8890
    /**
8891
     * Determines whether the this object is the outer most instance of itself
8892
     * Since DataHandler can create nested objects of itself,
8893
     * this method helps to determine the first (= outer most) one.
8894
     *
8895
     * @return bool
8896
     */
8897
    public function isOuterMostInstance()
8898
    {
8899
        return $this->getOuterMostInstance() === $this;
8900
    }
8901
8902
    /**
8903
     * Gets an instance of the runtime cache.
8904
     *
8905
     * @return FrontendInterface
8906
     */
8907
    protected function getRuntimeCache()
8908
    {
8909
        return $this->getCacheManager()->getCache('runtime');
8910
    }
8911
8912
    /**
8913
     * Determines nested element calls.
8914
     *
8915
     * @param string $table Name of the table
8916
     * @param int $id Uid of the record
8917
     * @param string $identifier Name of the action to be checked
8918
     * @return bool
8919
     */
8920
    protected function isNestedElementCallRegistered($table, $id, $identifier)
8921
    {
8922
        $nestedElementCalls = (array)$this->runtimeCache->get($this->cachePrefixNestedElementCalls);
8923
        return isset($nestedElementCalls[$identifier][$table][$id]);
8924
    }
8925
8926
    /**
8927
     * Registers nested elements calls.
8928
     * This is used to track nested calls (e.g. for following m:n relations).
8929
     *
8930
     * @param string $table Name of the table
8931
     * @param int $id Uid of the record
8932
     * @param string $identifier Name of the action to be tracked
8933
     */
8934
    protected function registerNestedElementCall($table, $id, $identifier)
8935
    {
8936
        $nestedElementCalls = (array)$this->runtimeCache->get($this->cachePrefixNestedElementCalls);
8937
        $nestedElementCalls[$identifier][$table][$id] = true;
8938
        $this->runtimeCache->set($this->cachePrefixNestedElementCalls, $nestedElementCalls);
8939
    }
8940
8941
    /**
8942
     * Resets the nested element calls.
8943
     */
8944
    protected function resetNestedElementCalls()
8945
    {
8946
        $this->runtimeCache->remove($this->cachePrefixNestedElementCalls);
8947
    }
8948
8949
    /**
8950
     * Determines whether an element was registered to be deleted in the registry.
8951
     *
8952
     * @param string $table Name of the table
8953
     * @param int $id Uid of the record
8954
     * @return bool
8955
     * @see registerElementsToBeDeleted
8956
     * @see resetElementsToBeDeleted
8957
     * @see copyRecord_raw
8958
     * @see versionizeRecord
8959
     */
8960
    protected function isElementToBeDeleted($table, $id)
8961
    {
8962
        $elementsToBeDeleted = (array)$this->runtimeCache->get('core-datahandler-elementsToBeDeleted');
8963
        return isset($elementsToBeDeleted[$table][$id]);
8964
    }
8965
8966
    /**
8967
     * Registers elements to be deleted in the registry.
8968
     *
8969
     * @see process_datamap
8970
     */
8971
    protected function registerElementsToBeDeleted()
8972
    {
8973
        $elementsToBeDeleted = (array)$this->runtimeCache->get('core-datahandler-elementsToBeDeleted');
8974
        $this->runtimeCache->set('core-datahandler-elementsToBeDeleted', array_merge($elementsToBeDeleted, $this->getCommandMapElements('delete')));
8975
    }
8976
8977
    /**
8978
     * Resets the elements to be deleted in the registry.
8979
     *
8980
     * @see process_datamap
8981
     */
8982
    protected function resetElementsToBeDeleted()
8983
    {
8984
        $this->runtimeCache->remove('core-datahandler-elementsToBeDeleted');
8985
    }
8986
8987
    /**
8988
     * Unsets elements (e.g. of the data map) that shall be deleted.
8989
     * This avoids to modify records that will be deleted later on.
8990
     *
8991
     * @param array $elements Elements to be modified
8992
     * @return array
8993
     */
8994
    protected function unsetElementsToBeDeleted(array $elements)
8995
    {
8996
        $elements = ArrayUtility::arrayDiffAssocRecursive($elements, $this->getCommandMapElements('delete'));
8997
        foreach ($elements as $key => $value) {
8998
            if (empty($value)) {
8999
                unset($elements[$key]);
9000
            }
9001
        }
9002
        return $elements;
9003
    }
9004
9005
    /**
9006
     * Gets elements of the command map that match a particular command.
9007
     *
9008
     * @param string $needle The command to be matched
9009
     * @return array
9010
     */
9011
    protected function getCommandMapElements($needle)
9012
    {
9013
        $elements = [];
9014
        foreach ($this->cmdmap as $tableName => $idArray) {
9015
            foreach ($idArray as $id => $commandArray) {
9016
                foreach ($commandArray as $command => $value) {
9017
                    if ($value && $command == $needle) {
9018
                        $elements[$tableName][$id] = true;
9019
                    }
9020
                }
9021
            }
9022
        }
9023
        return $elements;
9024
    }
9025
9026
    /**
9027
     * Controls active elements and sets NULL values if not active.
9028
     * Datamap is modified accordant to submitted control values.
9029
     */
9030
    protected function controlActiveElements()
9031
    {
9032
        if (!empty($this->control['active'])) {
9033
            $this->setNullValues(
9034
                $this->control['active'],
9035
                $this->datamap
9036
            );
9037
        }
9038
    }
9039
9040
    /**
9041
     * Sets NULL values in haystack array.
9042
     * The general behaviour in the user interface is to enable/activate fields.
9043
     * Thus, this method uses NULL as value to be stored if a field is not active.
9044
     *
9045
     * @param array $active hierarchical array with active elements
9046
     * @param array $haystack hierarchical array with haystack to be modified
9047
     */
9048
    protected function setNullValues(array $active, array &$haystack)
9049
    {
9050
        foreach ($active as $key => $value) {
9051
            // Nested data is processes recursively
9052
            if (is_array($value)) {
9053
                $this->setNullValues(
9054
                    $value,
9055
                    $haystack[$key]
9056
                );
9057
            } elseif ($value == 0) {
9058
                // Field has not been activated in the user interface,
9059
                // thus a NULL value shall be stored in the database
9060
                $haystack[$key] = null;
9061
            }
9062
        }
9063
    }
9064
9065
    /**
9066
     * @param CorrelationId $correlationId
9067
     */
9068
    public function setCorrelationId(CorrelationId $correlationId): void
9069
    {
9070
        $this->correlationId = $correlationId;
9071
    }
9072
9073
    /**
9074
     * @return CorrelationId|null
9075
     */
9076
    public function getCorrelationId(): ?CorrelationId
9077
    {
9078
        return $this->correlationId;
9079
    }
9080
9081
    /**
9082
     * Entry point to post process a database insert. Currently bails early unless a UID has been forced
9083
     * and the database platform is not MySQL.
9084
     *
9085
     * @param \TYPO3\CMS\Core\Database\Connection $connection
9086
     * @param string $tableName
9087
     * @param int $suggestedUid
9088
     * @return int
9089
     */
9090
    protected function postProcessDatabaseInsert(Connection $connection, string $tableName, int $suggestedUid): int
9091
    {
9092
        if ($suggestedUid !== 0 && $connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
9093
            $this->postProcessPostgresqlInsert($connection, $tableName);
9094
            // The last inserted id on postgresql is actually the last value generated by the sequence.
9095
            // On a forced UID insert this might not be the actual value or the sequence might not even
9096
            // have generated a value yet.
9097
            // Return the actual ID we forced on insert as a surrogate.
9098
            return $suggestedUid;
9099
        }
9100
        if ($connection->getDatabasePlatform() instanceof SQLServerPlatform) {
9101
            return $this->postProcessSqlServerInsert($connection, $tableName);
9102
        }
9103
        $id = $connection->lastInsertId($tableName);
9104
        return (int)$id;
9105
    }
9106
9107
    /**
9108
     * Get the last insert ID from sql server
9109
     *
9110
     * - first checks whether doctrine might be able to fetch the ID from the
9111
     * sequence table
9112
     * - if that does not succeed it manually selects the current IDENTITY value
9113
     * from a table
9114
     * - returns 0 if both fail
9115
     *
9116
     * @param \TYPO3\CMS\Core\Database\Connection $connection
9117
     * @param string $tableName
9118
     * @return int
9119
     * @throws \Doctrine\DBAL\Exception
9120
     */
9121
    protected function postProcessSqlServerInsert(Connection $connection, string $tableName): int
9122
    {
9123
        $id = $connection->lastInsertId($tableName);
9124
        if (!((int)$id > 0)) {
9125
            $table = $connection->quoteIdentifier($tableName);
9126
            $result = $connection->executeQuery('SELECT IDENT_CURRENT(\'' . $table . '\') AS id')->fetch();
9127
            if (isset($result['id']) && $result['id'] > 0) {
9128
                $id = $result['id'];
9129
            }
9130
        }
9131
        return (int)$id;
9132
    }
9133
9134
    /**
9135
     * PostgreSQL works with sequences for auto increment columns. A sequence is not updated when a value is
9136
     * written to such a column. To avoid clashes when the sequence returns an existing ID this helper will
9137
     * update the sequence to the current max value of the column.
9138
     *
9139
     * @param \TYPO3\CMS\Core\Database\Connection $connection
9140
     * @param string $tableName
9141
     */
9142
    protected function postProcessPostgresqlInsert(Connection $connection, string $tableName)
9143
    {
9144
        $queryBuilder = $connection->createQueryBuilder();
9145
        $queryBuilder->getRestrictions()->removeAll();
9146
        $row = $queryBuilder->select('PGT.schemaname', 'S.relname', 'C.attname', 'T.relname AS tablename')
9147
            ->from('pg_class', 'S')
9148
            ->from('pg_depend', 'D')
9149
            ->from('pg_class', 'T')
9150
            ->from('pg_attribute', 'C')
9151
            ->from('pg_tables', 'PGT')
9152
            ->where(
9153
                $queryBuilder->expr()->eq('S.relkind', $queryBuilder->quote('S')),
9154
                $queryBuilder->expr()->eq('S.oid', $queryBuilder->quoteIdentifier('D.objid')),
9155
                $queryBuilder->expr()->eq('D.refobjid', $queryBuilder->quoteIdentifier('T.oid')),
9156
                $queryBuilder->expr()->eq('D.refobjid', $queryBuilder->quoteIdentifier('C.attrelid')),
9157
                $queryBuilder->expr()->eq('D.refobjsubid', $queryBuilder->quoteIdentifier('C.attnum')),
9158
                $queryBuilder->expr()->eq('T.relname', $queryBuilder->quoteIdentifier('PGT.tablename')),
9159
                $queryBuilder->expr()->eq('PGT.tablename', $queryBuilder->quote($tableName))
9160
            )
9161
            ->setMaxResults(1)
9162
            ->execute()
9163
            ->fetch();
9164
9165
        if ($row !== false) {
9166
            $connection->exec(
9167
                sprintf(
9168
                    'SELECT SETVAL(%s, COALESCE(MAX(%s), 0)+1, FALSE) FROM %s',
9169
                    $connection->quote($row['schemaname'] . '.' . $row['relname']),
9170
                    $connection->quoteIdentifier($row['attname']),
9171
                    $connection->quoteIdentifier($row['schemaname'] . '.' . $row['tablename'])
9172
                )
9173
            );
9174
        }
9175
    }
9176
9177
    /**
9178
     * Return the cache entry identifier for field evals
9179
     *
9180
     * @param string $additionalIdentifier
9181
     * @return string
9182
     */
9183
    protected function getFieldEvalCacheIdentifier($additionalIdentifier)
9184
    {
9185
        return 'core-datahandler-eval-' . md5($additionalIdentifier);
9186
    }
9187
9188
    /**
9189
     * @return RelationHandler
9190
     */
9191
    protected function createRelationHandlerInstance()
9192
    {
9193
        $isWorkspacesLoaded = ExtensionManagementUtility::isLoaded('workspaces');
9194
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
9195
        $relationHandler->setWorkspaceId($this->BE_USER->workspace);
9196
        $relationHandler->setUseLiveReferenceIds($isWorkspacesLoaded);
9197
        $relationHandler->setUseLiveParentIds($isWorkspacesLoaded);
9198
        $relationHandler->setReferenceIndexUpdater($this->referenceIndexUpdater);
9199
        return $relationHandler;
9200
    }
9201
9202
    /**
9203
     * Create and returns an instance of the CacheManager
9204
     *
9205
     * @return CacheManager
9206
     */
9207
    protected function getCacheManager()
9208
    {
9209
        return GeneralUtility::makeInstance(CacheManager::class);
9210
    }
9211
9212
    /**
9213
     * Gets the resourceFactory
9214
     *
9215
     * @return ResourceFactory
9216
     */
9217
    protected function getResourceFactory()
9218
    {
9219
        return GeneralUtility::makeInstance(ResourceFactory::class);
9220
    }
9221
9222
    /**
9223
     * @return LanguageService
9224
     */
9225
    protected function getLanguageService()
9226
    {
9227
        return $GLOBALS['LANG'];
9228
    }
9229
9230
    /**
9231
     * @internal should only be used from within TYPO3 Core
9232
     * @return array
9233
     */
9234
    public function getHistoryRecords(): array
9235
    {
9236
        return $this->historyRecords;
9237
    }
9238
}
9239