Passed
Push — master ( 2f6fb7...63c6d9 )
by
unknown
15:23
created

DataHandler::processRemapStack()   D

Complexity

Conditions 30
Paths 6

Size

Total Lines 138
Code Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 30
eloc 83
nc 6
nop 0
dl 0
loc 138
rs 4.1666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\QueryHelper;
41
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
42
use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface;
43
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
44
use TYPO3\CMS\Core\Database\RelationHandler;
45
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
46
use TYPO3\CMS\Core\DataHandling\Localization\DataMapProcessor;
47
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
48
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
49
use TYPO3\CMS\Core\Html\RteHtmlParser;
50
use TYPO3\CMS\Core\Localization\LanguageService;
51
use TYPO3\CMS\Core\Messaging\FlashMessage;
52
use TYPO3\CMS\Core\Messaging\FlashMessageService;
53
use TYPO3\CMS\Core\Resource\ResourceFactory;
54
use TYPO3\CMS\Core\Service\OpcodeCacheService;
55
use TYPO3\CMS\Core\SysLog\Action as SystemLogGenericAction;
56
use TYPO3\CMS\Core\SysLog\Action\Cache as SystemLogCacheAction;
57
use TYPO3\CMS\Core\SysLog\Action\Database as SystemLogDatabaseAction;
58
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
59
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
60
use TYPO3\CMS\Core\Type\Bitmask\Permission;
61
use TYPO3\CMS\Core\Utility\ArrayUtility;
62
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
63
use TYPO3\CMS\Core\Utility\GeneralUtility;
64
use TYPO3\CMS\Core\Utility\HttpUtility;
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])) {
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
                    if ($table === 'pages' && $incomingFieldArray[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0 && $incomingFieldArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] > 0) {
945
                        $recordAccess = $this->checkRecordInsertAccess($table, $incomingFieldArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]);
946
                    } else {
947
                        $recordAccess = $this->checkRecordInsertAccess($table, $theRealPid);
948
                    }
949
                    if ($recordAccess) {
950
                        $this->addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray);
951
                        $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $incomingFieldArray, true);
952
                        if (!$recordAccess) {
953
                            $this->newlog('recordEditAccessInternals() check failed. [' . $this->BE_USER->errorMsg . ']', SystemLogErrorClassification::USER_ERROR);
954
                        } elseif (!$this->bypassWorkspaceRestrictions && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
955
                            // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record
956
                            // So, if no live records were allowed in the current workspace, we have to create a new version of this record
957
                            if (BackendUtility::isTableWorkspaceEnabled($table)) {
958
                                $createNewVersion = true;
959
                            } else {
960
                                $recordAccess = false;
961
                                $this->newlog('Record could not be created in this workspace', SystemLogErrorClassification::USER_ERROR);
962
                            }
963
                        }
964
                    }
965
                    // Yes new record, change $record_status to 'insert'
966
                    $status = 'new';
967
                } else {
968
                    // Nope... $id is a number
969
                    $id = (int)$id;
970
                    $fieldArray = [];
971
                    $recordAccess = $this->checkRecordUpdateAccess($table, $id, $incomingFieldArray, $hookObjectsArr);
972
                    if (!$recordAccess) {
973
                        if ($this->enableLogging) {
974
                            $propArr = $this->getRecordProperties($table, $id);
975
                            $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']);
976
                        }
977
                        continue;
978
                    }
979
                    // Next check of the record permissions (internals)
980
                    $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $id);
981
                    if (!$recordAccess) {
982
                        $this->newlog('recordEditAccessInternals() check failed. [' . $this->BE_USER->errorMsg . ']', SystemLogErrorClassification::USER_ERROR);
983
                    } else {
984
                        // Here we fetch the PID of the record that we point to...
985
                        $tempdata = $this->recordInfo($table, $id, 'pid' . (BackendUtility::isTableWorkspaceEnabled($table) ? ',t3ver_oid,t3ver_wsid,t3ver_stage' : ''));
986
                        $theRealPid = $tempdata['pid'] ?? null;
987
                        // Use the new id of the versionized record we're trying to write to:
988
                        // (This record is a child record of a parent and has already been versionized.)
989
                        if (!empty($this->autoVersionIdMap[$table][$id])) {
990
                            // For the reason that creating a new version of this record, automatically
991
                            // created related child records (e.g. "IRRE"), update the accordant field:
992
                            $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
993
                            // Use the new id of the copied/versionized record:
994
                            $id = $this->autoVersionIdMap[$table][$id];
995
                            $recordAccess = true;
996
                        } elseif (!$this->bypassWorkspaceRestrictions && ($errorCode = $this->BE_USER->workspaceCannotEditRecord($table, $tempdata))) {
997
                            $recordAccess = false;
998
                            // Versioning is required and it must be offline version!
999
                            // Check if there already is a workspace version
1000
                            $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid,t3ver_oid');
1001
                            if ($workspaceVersion) {
1002
                                $id = $workspaceVersion['uid'];
1003
                                $recordAccess = true;
1004
                            } elseif ($this->BE_USER->workspaceAllowAutoCreation($table, $id, $theRealPid)) {
1005
                                // new version of a record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1006
                                $this->pagetreeNeedsRefresh = true;
1007
1008
                                /** @var DataHandler $tce */
1009
                                $tce = GeneralUtility::makeInstance(__CLASS__, $this->referenceIndexUpdater);
1010
                                $tce->enableLogging = $this->enableLogging;
1011
                                // Setting up command for creating a new version of the record:
1012
                                $cmd = [];
1013
                                $cmd[$table][$id]['version'] = [
1014
                                    'action' => 'new',
1015
                                    // Default is to create a version of the individual records
1016
                                    'label' => 'Auto-created for WS #' . $this->BE_USER->workspace
1017
                                ];
1018
                                $tce->start([], $cmd, $this->BE_USER);
1019
                                $tce->process_cmdmap();
1020
                                $this->errorLog = array_merge($this->errorLog, $tce->errorLog);
1021
                                // If copying was successful, share the new uids (also of related children):
1022
                                if (!empty($tce->copyMappingArray[$table][$id])) {
1023
                                    foreach ($tce->copyMappingArray as $origTable => $origIdArray) {
1024
                                        foreach ($origIdArray as $origId => $newId) {
1025
                                            $this->autoVersionIdMap[$origTable][$origId] = $newId;
1026
                                        }
1027
                                    }
1028
                                    // Update registerDBList, that holds the copied relations to child records:
1029
                                    $registerDBList = array_merge($registerDBList, $tce->registerDBList);
1030
                                    // For the reason that creating a new version of this record, automatically
1031
                                    // created related child records (e.g. "IRRE"), update the accordant field:
1032
                                    $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
1033
                                    // Use the new id of the copied/versionized record:
1034
                                    $id = $this->autoVersionIdMap[$table][$id];
1035
                                    $recordAccess = true;
1036
                                } else {
1037
                                    $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);
1038
                                }
1039
                            } else {
1040
                                $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);
1041
                            }
1042
                        }
1043
                    }
1044
                    // The default is 'update'
1045
                    $status = 'update';
1046
                }
1047
                // If access was granted above, proceed to create or update record:
1048
                if (!$recordAccess) {
1049
                    continue;
1050
                }
1051
1052
                // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array.
1053
                [$tscPID] = BackendUtility::getTSCpid($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0));
1054
                if ($status === 'new') {
1055
                    // Apply TCAdefaults from pageTS
1056
                    $fieldArray = $this->applyDefaultsForFieldArray($table, (int)$tscPID, $fieldArray);
1057
                    // Apply page permissions as well
1058
                    if ($table === 'pages') {
1059
                        $fieldArray = $this->pagePermissionAssembler->applyDefaults(
1060
                            $fieldArray,
1061
                            (int)$tscPID,
1062
                            (int)$this->userid,
1063
                            (int)$this->BE_USER->firstMainGroup
1064
                        );
1065
                    }
1066
                }
1067
                // Processing of all fields in incomingFieldArray and setting them in $fieldArray
1068
                $fieldArray = $this->fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
1069
                // NOTICE! All manipulation beyond this point bypasses both "excludeFields" AND possible "MM" relations to field!
1070
                // Forcing some values unto field array:
1071
                // NOTICE: This overriding is potentially dangerous; permissions per field is not checked!!!
1072
                $fieldArray = $this->overrideFieldArray($table, $fieldArray);
1073
                // Setting system fields
1074
                if ($status === 'new') {
1075
                    if ($GLOBALS['TCA'][$table]['ctrl']['crdate']) {
1076
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
1077
                    }
1078
                    if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id']) {
1079
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
1080
                    }
1081
                } elseif ($this->checkSimilar) {
1082
                    // Removing fields which are equal to the current value:
1083
                    $fieldArray = $this->compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray);
1084
                }
1085
                if ($GLOBALS['TCA'][$table]['ctrl']['tstamp'] && !empty($fieldArray)) {
1086
                    $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
1087
                }
1088
                // Set stage to "Editing" to make sure we restart the workflow
1089
                if (BackendUtility::isTableWorkspaceEnabled($table)) {
1090
                    $fieldArray['t3ver_stage'] = 0;
1091
                }
1092
                // Hook: processDatamap_postProcessFieldArray
1093
                foreach ($hookObjectsArr as $hookObj) {
1094
                    if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) {
1095
                        $hookObj->processDatamap_postProcessFieldArray($status, $table, $id, $fieldArray, $this);
1096
                    }
1097
                }
1098
                // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything
1099
                // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already
1100
                if (is_array($fieldArray)) {
1101
                    if ($status === 'new') {
1102
                        if ($table === 'pages') {
1103
                            // for new pages always a refresh is needed
1104
                            $this->pagetreeNeedsRefresh = true;
1105
                        }
1106
1107
                        // This creates a version of the record, instead of adding it to the live workspace
1108
                        if ($createNewVersion) {
1109
                            // new record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1110
                            $this->pagetreeNeedsRefresh = true;
1111
                            $fieldArray['pid'] = $theRealPid;
1112
                            $fieldArray['t3ver_oid'] = 0;
1113
                            // Setting state for version (so it can know it is currently a new version...)
1114
                            $fieldArray['t3ver_state'] = (string)new VersionState(VersionState::NEW_PLACEHOLDER);
1115
                            $fieldArray['t3ver_wsid'] = $this->BE_USER->workspace;
1116
                            $this->insertDB($table, $id, $fieldArray, true, (int)($incomingFieldArray['uid'] ?? 0));
1117
                            // Hold auto-versionized ids of placeholders
1118
                            $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id];
1119
                        } else {
1120
                            $this->insertDB($table, $id, $fieldArray, false, (int)($incomingFieldArray['uid'] ?? 0));
1121
                        }
1122
                    } else {
1123
                        if ($table === 'pages') {
1124
                            // only a certain number of fields needs to be checked for updates
1125
                            // if $this->checkSimilar is TRUE, fields with unchanged values are already removed here
1126
                            $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray));
1127
                            if (!empty($fieldsToCheck)) {
1128
                                $this->pagetreeNeedsRefresh = true;
1129
                            }
1130
                        }
1131
                        $this->updateDB($table, $id, $fieldArray);
1132
                    }
1133
                }
1134
                // Hook: processDatamap_afterDatabaseOperations
1135
                // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
1136
                // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
1137
                $this->hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, $table, $id, $fieldArray);
1138
            }
1139
        }
1140
        // Process the stack of relations to remap/correct
1141
        $this->processRemapStack();
1142
        $this->dbAnalysisStoreExec();
1143
        // Hook: processDatamap_afterAllOperations
1144
        // Note: When this hook gets called, all operations on the submitted data have been finished.
1145
        foreach ($hookObjectsArr as $hookObj) {
1146
            if (method_exists($hookObj, 'processDatamap_afterAllOperations')) {
1147
                $hookObj->processDatamap_afterAllOperations($this);
1148
            }
1149
        }
1150
1151
        if ($this->isOuterMostInstance()) {
1152
            $this->referenceIndexUpdater->update();
1153
            $this->processClearCacheQueue();
1154
            $this->resetElementsToBeDeleted();
1155
        }
1156
    }
1157
1158
    /**
1159
     * @param string $table
1160
     * @param string $value
1161
     * @param string $dbType
1162
     * @return string
1163
     */
1164
    protected function normalizeTimeFormat(string $table, string $value, string $dbType): string
1165
    {
1166
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
1167
        $platform = $connection->getDatabasePlatform();
1168
        if ($platform instanceof SQLServerPlatform) {
1169
            $defaultLength = QueryHelper::getDateTimeFormats()[$dbType]['empty'];
1170
            $value = substr(
1171
                $value,
1172
                0,
1173
                strlen($defaultLength)
1174
            );
1175
        }
1176
        return $value;
1177
    }
1178
1179
    /**
1180
     * Sets the "sorting" DB field and the "pid" field of an incoming record that should be added (NEW1234)
1181
     * depending on the record that should be added or where it should be added.
1182
     *
1183
     * This method is called from process_datamap()
1184
     *
1185
     * @param string $table the table name of the record to insert
1186
     * @param int $pid the real PID (numeric) where the record should be
1187
     * @param array $fieldArray field+value pairs to add
1188
     * @return array the modified field array
1189
     */
1190
    protected function resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array
1191
    {
1192
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
1193
        // Points to a page on which to insert the element, possibly in the top of the page
1194
        if ($pid >= 0) {
1195
            // Ensure that the "pid" is not a translated page ID, but the default page ID
1196
            $pid = $this->getDefaultLanguagePageId($pid);
1197
            // The numerical pid is inserted in the data array
1198
            $fieldArray['pid'] = $pid;
1199
            // If this table is sorted we better find the top sorting number
1200
            if ($sortColumn) {
1201
                $fieldArray[$sortColumn] = $this->getSortNumber($table, 0, $pid);
1202
            }
1203
        } elseif ($sortColumn) {
1204
            // Points to another record before itself
1205
            // If this table is sorted we better find the top sorting number
1206
            // Because $pid is < 0, getSortNumber() returns an array
1207
            $sortingInfo = $this->getSortNumber($table, 0, $pid);
1208
            $fieldArray['pid'] = $sortingInfo['pid'];
1209
            $fieldArray[$sortColumn] = $sortingInfo['sortNumber'];
1210
        } else {
1211
            // Here we fetch the PID of the record that we point to
1212
            $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

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

1395
                    $propArr = $this->getRecordProperties($table, /** @scrutinizer ignore-type */ $id);
Loading history...
1396
                    $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']);
1397
                }
1398
                return $res;
1399
            }
1400
            if ($status === 'update') {
1401
                // This checks 1) if we should check for disallowed tables and 2) if there are records from disallowed tables on the current page
1402
                $onlyAllowedTables = $GLOBALS['PAGES_TYPES'][$value]['onlyAllowedTables'] ?? $GLOBALS['PAGES_TYPES']['default']['onlyAllowedTables'];
1403
                if ($onlyAllowedTables) {
1404
                    // use the real page id (default language)
1405
                    $recordId = $this->getDefaultLanguagePageId((int)$id);
1406
                    $theWrongTables = $this->doesPageHaveUnallowedTables($recordId, (int)$value);
1407
                    if ($theWrongTables) {
1408
                        if ($this->enableLogging) {
1409
                            $propArr = $this->getRecordProperties($table, $id);
1410
                            $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']);
1411
                        }
1412
                        return $res;
1413
                    }
1414
                }
1415
            }
1416
        }
1417
1418
        $curValue = null;
1419
        if ((int)$id !== 0) {
1420
            // Get current value:
1421
            $curValueRec = $this->recordInfo($table, (int)$id, $field);
1422
            // isset() won't work here, since values can be NULL
1423
            if ($curValueRec !== null && array_key_exists($field, $curValueRec)) {
1424
                $curValue = $curValueRec[$field];
1425
            }
1426
        }
1427
1428
        if ($table === 'be_users'
1429
            && ($field === 'admin' || $field === 'password')
1430
            && $status === 'update'
1431
        ) {
1432
            // Do not allow a non system maintainer admin to change admin flag and password of system maintainers
1433
            $systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
1434
            // False if current user is not in system maintainer list or if switch to user mode is active
1435
            $isCurrentUserSystemMaintainer = $this->BE_USER->isSystemMaintainer();
1436
            $isTargetUserInSystemMaintainerList = in_array((int)$id, $systemMaintainers, true);
1437
            if ($field === 'admin') {
1438
                $isFieldChanged = (int)$curValueRec[$field] !== (int)$value;
1439
            } else {
1440
                $isFieldChanged = $curValueRec[$field] !== $value;
1441
            }
1442
            if (!$isCurrentUserSystemMaintainer && $isTargetUserInSystemMaintainerList && $isFieldChanged) {
1443
                $value = $curValueRec[$field];
1444
                $message = GeneralUtility::makeInstance(
1445
                    FlashMessage::class,
1446
                    $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.adminCanNotChangeSystemMaintainer'),
1447
                    '',
1448
                    FlashMessage::ERROR,
1449
                    true
1450
                );
1451
                $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
1452
                $flashMessageService->getMessageQueueByIdentifier()->enqueue($message);
1453
            }
1454
        }
1455
1456
        // Getting config for the field
1457
        $tcaFieldConf = $this->resolveFieldConfigurationAndRespectColumnsOverrides($table, $field);
1458
1459
        // Create $recFID only for those types that need it
1460
        if ($tcaFieldConf['type'] === 'flex') {
1461
            $recFID = $table . ':' . $id . ':' . $field;
1462
        } else {
1463
            $recFID = '';
1464
        }
1465
1466
        // Perform processing:
1467
        $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

1467
        $res = $this->checkValue_SW($res, $value, $tcaFieldConf, $table, /** @scrutinizer ignore-type */ $id, $curValue, $status, $realPid, $recFID, $field, [], $tscPID, ['incomingFieldArray' => $incomingFieldArray]);
Loading history...
1468
        return $res;
1469
    }
1470
1471
    /**
1472
     * Use columns overrides for evaluation.
1473
     *
1474
     * Fetch the TCA ["config"] part for a specific field, including the columnsOverrides value.
1475
     * Used for checkValue purposes currently (as it takes the checkValue_currentRecord value).
1476
     *
1477
     * @param string $table
1478
     * @param string $field
1479
     * @return array
1480
     */
1481
    protected function resolveFieldConfigurationAndRespectColumnsOverrides(string $table, string $field): array
1482
    {
1483
        $tcaFieldConf = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
1484
        $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1485
        $columnsOverridesConfigOfField = $GLOBALS['TCA'][$table]['types'][$recordType]['columnsOverrides'][$field]['config'] ?? null;
1486
        if ($columnsOverridesConfigOfField) {
1487
            ArrayUtility::mergeRecursiveWithOverrule($tcaFieldConf, $columnsOverridesConfigOfField);
1488
        }
1489
        return $tcaFieldConf;
1490
    }
1491
1492
    /**
1493
     * Branches out evaluation of a field value based on its type as configured in $GLOBALS['TCA']
1494
     * Can be called for FlexForm pseudo fields as well, BUT must not have $field set if so.
1495
     *
1496
     * @param array $res The result array. The processed value (if any!) is set in the "value" key.
1497
     * @param string $value The value to set.
1498
     * @param array $tcaFieldConf Field configuration from $GLOBALS['TCA']
1499
     * @param string $table Table name
1500
     * @param int $id UID of record
1501
     * @param mixed $curValue Current value of the field
1502
     * @param string $status 'update' or 'new' flag
1503
     * @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.
1504
     * @param string $recFID Field identifier [table:uid:field] for flexforms
1505
     * @param string $field Field name. Must NOT be set if the call is for a flexform field (since flexforms are not allowed within flexforms).
1506
     * @param array $uploadedFiles
1507
     * @param int $tscPID TSconfig PID
1508
     * @param array $additionalData Additional data to be forwarded to sub-processors
1509
     * @return array Returns the evaluated $value as key "value" in this array.
1510
     * @internal should only be used from within DataHandler
1511
     */
1512
    public function checkValue_SW($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $field, $uploadedFiles, $tscPID, array $additionalData = null)
1513
    {
1514
        // Convert to NULL value if defined in TCA
1515
        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...
1516
            $res = ['value' => null];
1517
            return $res;
1518
        }
1519
1520
        switch ($tcaFieldConf['type']) {
1521
            case 'text':
1522
                $res = $this->checkValueForText($value, $tcaFieldConf, $table, $id, $realPid, $field);
1523
                break;
1524
            case 'passthrough':
1525
            case 'imageManipulation':
1526
            case 'user':
1527
                $res['value'] = $value;
1528
                break;
1529
            case 'input':
1530
                $res = $this->checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field);
1531
                break;
1532
            case 'slug':
1533
                $res = $this->checkValueForSlug((string)$value, $tcaFieldConf, $table, $id, (int)$realPid, $field, $additionalData['incomingFieldArray'] ?? []);
1534
                break;
1535
            case 'check':
1536
                $res = $this->checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
1537
                break;
1538
            case 'radio':
1539
                $res = $this->checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $realPid, $field);
1540
                break;
1541
            case 'group':
1542
            case 'select':
1543
                $res = $this->checkValueForGroupSelect($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $recFID, $uploadedFiles, $field);
1544
                break;
1545
            case 'inline':
1546
                $res = $this->checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData) ?: [];
1547
                break;
1548
            case 'flex':
1549
                // FlexForms are only allowed for real fields.
1550
                if ($field) {
1551
                    $res = $this->checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $uploadedFiles, $field);
1552
                }
1553
                break;
1554
            default:
1555
                // Do nothing
1556
        }
1557
        $res = $this->checkValueForInternalReferences($res, $value, $tcaFieldConf, $table, $id, $field);
1558
        return $res;
1559
    }
1560
1561
    /**
1562
     * Checks values that are used for internal references. If the provided $value
1563
     * is a NEW-identifier, the direct processing is stopped. Instead, the value is
1564
     * forwarded to the remap-stack to be post-processed and resolved into a proper
1565
     * UID after all data has been resolved.
1566
     *
1567
     * This method considers TCA types that cannot handle and resolve these internal
1568
     * values directly, like 'passthrough', 'none' or 'user'. Values are only modified
1569
     * here if the $field is used as 'transOrigPointerField' or 'translationSource'.
1570
     *
1571
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1572
     * @param string $value The value to set.
1573
     * @param array $tcaFieldConf Field configuration from TCA
1574
     * @param string $table Table name
1575
     * @param int|string $id UID of record
1576
     * @param string $field The field name
1577
     * @return array The result array. The processed value (if any!) is set in the "value" key.
1578
     */
1579
    protected function checkValueForInternalReferences(array $res, $value, $tcaFieldConf, $table, $id, $field)
1580
    {
1581
        $relevantFieldNames = [
1582
            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null,
1583
            $GLOBALS['TCA'][$table]['ctrl']['translationSource'] ?? null,
1584
        ];
1585
1586
        if (
1587
            // in case field is empty
1588
            empty($field)
1589
            // in case the field is not relevant
1590
            || !in_array($field, $relevantFieldNames)
1591
            // in case the 'value' index has been unset already
1592
            || !array_key_exists('value', $res)
1593
            // in case it's not a NEW-identifier
1594
            || strpos($value, 'NEW') === false
1595
        ) {
1596
            return $res;
1597
        }
1598
1599
        $valueArray = [$value];
1600
        $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1601
        $this->addNewValuesToRemapStackChildIds($valueArray);
1602
        $this->remapStack[] = [
1603
            'args' => [$valueArray, $tcaFieldConf, $id, $table, $field],
1604
            'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 3],
1605
            'field' => $field
1606
        ];
1607
        unset($res['value']);
1608
1609
        return $res;
1610
    }
1611
1612
    /**
1613
     * Evaluate "text" type values.
1614
     *
1615
     * @param string $value The value to set.
1616
     * @param array $tcaFieldConf Field configuration from TCA
1617
     * @param string $table Table name
1618
     * @param int $id UID of record
1619
     * @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.
1620
     * @param string $field Field name
1621
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1622
     */
1623
    protected function checkValueForText($value, $tcaFieldConf, $table, $id, $realPid, $field)
1624
    {
1625
        if (isset($tcaFieldConf['eval']) && $tcaFieldConf['eval'] !== '') {
1626
            $cacheId = $this->getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1627
            $evalCodesArray = $this->runtimeCache->get($cacheId);
1628
            if (!is_array($evalCodesArray)) {
1629
                $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1630
                $this->runtimeCache->set($cacheId, $evalCodesArray);
1631
            }
1632
            $valueArray = $this->checkValue_text_Eval($value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '');
1633
        } else {
1634
            $valueArray = ['value' => $value];
1635
        }
1636
1637
        // Handle richtext transformations
1638
        if ($this->dontProcessTransformations) {
1639
            return $valueArray;
1640
        }
1641
        // Keep null as value
1642
        if ($value === null) {
0 ignored issues
show
introduced by
The condition $value === null is always false.
Loading history...
1643
            return $valueArray;
1644
        }
1645
        if (isset($tcaFieldConf['enableRichtext']) && (bool)$tcaFieldConf['enableRichtext'] === true) {
1646
            $recordType = BackendUtility::getTCAtypeValue($table, $this->checkValue_currentRecord);
1647
            $richtextConfigurationProvider = GeneralUtility::makeInstance(Richtext::class);
1648
            $richtextConfiguration = $richtextConfigurationProvider->getConfiguration($table, $field, $realPid, $recordType, $tcaFieldConf);
1649
            $rteParser = GeneralUtility::makeInstance(RteHtmlParser::class);
1650
            $valueArray['value'] = $rteParser->transformTextForPersistence((string)$value, $richtextConfiguration['proc.'] ?? []);
1651
        }
1652
1653
        return $valueArray;
1654
    }
1655
1656
    /**
1657
     * Evaluate "input" type values.
1658
     *
1659
     * @param string $value The value to set.
1660
     * @param array $tcaFieldConf Field configuration from TCA
1661
     * @param string $table Table name
1662
     * @param int $id UID of record
1663
     * @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.
1664
     * @param string $field Field name
1665
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1666
     */
1667
    protected function checkValueForInput($value, $tcaFieldConf, $table, $id, $realPid, $field)
1668
    {
1669
        // Handle native date/time fields
1670
        $isDateOrDateTimeField = false;
1671
        $format = '';
1672
        $emptyValue = '';
1673
        $dateTimeTypes = QueryHelper::getDateTimeTypes();
1674
        // normal integer "date" fields (timestamps) are handled in checkValue_input_Eval
1675
        if (isset($tcaFieldConf['dbType']) && in_array($tcaFieldConf['dbType'], $dateTimeTypes, true)) {
1676
            if (empty($value)) {
1677
                $value = null;
1678
            } else {
1679
                $isDateOrDateTimeField = true;
1680
                $dateTimeFormats = QueryHelper::getDateTimeFormats();
1681
                $format = $dateTimeFormats[$tcaFieldConf['dbType']]['format'];
1682
1683
                // Convert the date/time into a timestamp for the sake of the checks
1684
                $emptyValue = $dateTimeFormats[$tcaFieldConf['dbType']]['empty'];
1685
                // We expect the ISO 8601 $value to contain a UTC timezone specifier.
1686
                // We explicitly fallback to UTC if no timezone specifier is given (e.g. for copy operations).
1687
                $dateTime = new \DateTime($value, new \DateTimeZone('UTC'));
1688
                // The timestamp (UTC) returned by getTimestamp() will be converted to
1689
                // a local time string by gmdate() later.
1690
                $value = $value === $emptyValue ? null : $dateTime->getTimestamp();
1691
            }
1692
        }
1693
        // Secures the string-length to be less than max.
1694
        if (isset($tcaFieldConf['max']) && (int)$tcaFieldConf['max'] > 0) {
1695
            $value = mb_substr((string)$value, 0, (int)$tcaFieldConf['max'], 'utf-8');
1696
        }
1697
1698
        if (empty($tcaFieldConf['eval'])) {
1699
            $res = ['value' => $value];
1700
        } else {
1701
            // Process evaluation settings:
1702
            $cacheId = $this->getFieldEvalCacheIdentifier($tcaFieldConf['eval']);
1703
            $evalCodesArray = $this->runtimeCache->get($cacheId);
1704
            if (!is_array($evalCodesArray)) {
1705
                $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1706
                $this->runtimeCache->set($cacheId, $evalCodesArray);
1707
            }
1708
1709
            $res = $this->checkValue_input_Eval((string)$value, $evalCodesArray, $tcaFieldConf['is_in'] ?? '', $table);
1710
            if (isset($tcaFieldConf['dbType']) && isset($res['value']) && !$res['value']) {
1711
                // set the value to null if we have an empty value for a native field
1712
                $res['value'] = null;
1713
            }
1714
1715
            // Process UNIQUE settings:
1716
            // 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
1717
            if ($field && !empty($res['value'])) {
1718
                if (in_array('uniqueInPid', $evalCodesArray, true)) {
1719
                    $res['value'] = $this->getUnique($table, $field, $res['value'], $id, $realPid);
1720
                }
1721
                if ($res['value'] && in_array('unique', $evalCodesArray, true)) {
1722
                    $res['value'] = $this->getUnique($table, $field, $res['value'], $id);
1723
                }
1724
            }
1725
        }
1726
1727
        // Checking range of value:
1728
        // @todo: The "checkbox" option was removed for type=input, this check could be probably relaxed?
1729
        if (
1730
            isset($tcaFieldConf['range']) && $tcaFieldConf['range']
1731
            && (!isset($tcaFieldConf['checkbox']) || $res['value'] != $tcaFieldConf['checkbox'])
1732
            && (!isset($tcaFieldConf['default']) || (int)$res['value'] !== (int)$tcaFieldConf['default'])
1733
        ) {
1734
            if (isset($tcaFieldConf['range']['upper']) && (int)$res['value'] > (int)$tcaFieldConf['range']['upper']) {
1735
                $res['value'] = (int)$tcaFieldConf['range']['upper'];
1736
            }
1737
            if (isset($tcaFieldConf['range']['lower']) && (int)$res['value'] < (int)$tcaFieldConf['range']['lower']) {
1738
                $res['value'] = (int)$tcaFieldConf['range']['lower'];
1739
            }
1740
        }
1741
1742
        // Handle native date/time fields
1743
        if ($isDateOrDateTimeField) {
1744
            // Convert the timestamp back to a date/time
1745
            $res['value'] = $res['value'] ? gmdate($format, $res['value']) : $emptyValue;
1746
        }
1747
        return $res;
1748
    }
1749
1750
    /**
1751
     * Evaluate "slug" type values.
1752
     *
1753
     * @param string $value The value to set.
1754
     * @param array $tcaFieldConf Field configuration from TCA
1755
     * @param string $table Table name
1756
     * @param int $id UID of record
1757
     * @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.
1758
     * @param string $field Field name
1759
     * @param array $incomingFieldArray the fields being explicitly set by the outside (unlike $fieldArray) for the record
1760
     * @return array $res The result array. The processed value (if any!) is set in the "value" key.
1761
     * @see SlugEnricher
1762
     * @see SlugHelper
1763
     */
1764
    protected function checkValueForSlug(string $value, array $tcaFieldConf, string $table, $id, int $realPid, string $field, array $incomingFieldArray = []): array
1765
    {
1766
        $workspaceId = $this->BE_USER->workspace;
1767
        $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $tcaFieldConf, $workspaceId);
1768
        $fullRecord = array_replace_recursive($this->checkValue_currentRecord, $incomingFieldArray ?? []);
1769
        // Generate a value if there is none, otherwise ensure that all characters are cleaned up
1770
        if ($value === '') {
1771
            $value = $helper->generate($fullRecord, $realPid);
1772
        } else {
1773
            $value = $helper->sanitize($value);
1774
        }
1775
1776
        // Return directly in case no evaluations are defined
1777
        if (empty($tcaFieldConf['eval'])) {
1778
            return ['value' => $value];
1779
        }
1780
1781
        $state = RecordStateFactory::forName($table)
1782
            ->fromArray($fullRecord, $realPid, $id);
1783
        $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1784
        if (in_array('unique', $evalCodesArray, true)) {
1785
            $value = $helper->buildSlugForUniqueInTable($value, $state);
1786
        }
1787
        if (in_array('uniqueInSite', $evalCodesArray, true)) {
1788
            $value = $helper->buildSlugForUniqueInSite($value, $state);
1789
        }
1790
        if (in_array('uniqueInPid', $evalCodesArray, true)) {
1791
            $value = $helper->buildSlugForUniqueInPid($value, $state);
1792
        }
1793
1794
        return ['value' => $value];
1795
    }
1796
1797
    /**
1798
     * Evaluates 'check' type values.
1799
     *
1800
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1801
     * @param string $value The value to set.
1802
     * @param array $tcaFieldConf Field configuration from TCA
1803
     * @param string $table Table name
1804
     * @param int $id UID of record
1805
     * @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.
1806
     * @param string $field Field name
1807
     * @return array Modified $res array
1808
     */
1809
    protected function checkValueForCheck($res, $value, $tcaFieldConf, $table, $id, $realPid, $field)
1810
    {
1811
        $items = $tcaFieldConf['items'] ?? null;
1812
        if (!empty($tcaFieldConf['itemsProcFunc'])) {
1813
            /** @var ItemProcessingService $processingService */
1814
            $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
1815
            $items = $processingService->getProcessingItems(
1816
                $table,
1817
                $realPid,
1818
                $field,
1819
                $this->checkValue_currentRecord,
1820
                $tcaFieldConf,
1821
                $tcaFieldConf['items']
1822
            );
1823
        }
1824
1825
        $itemC = 0;
1826
        if ($items !== null) {
1827
            $itemC = count($items);
1828
        }
1829
        if (!$itemC) {
1830
            $itemC = 1;
1831
        }
1832
        $maxV = (2 ** $itemC) - 1;
1833
        if ($value < 0) {
1834
            // @todo: throw LogicException here? Negative values for checkbox items do not make sense and indicate a coding error.
1835
            $value = 0;
1836
        }
1837
        if ($value > $maxV) {
1838
            // @todo: This case is pretty ugly: If there is an itemsProcFunc registered, and if it returns a dynamic,
1839
            // @todo: changing list of items, then it may happen that a value is transformed and vanished checkboxes
1840
            // @todo: are permanently removed from the value.
1841
            // @todo: Suggestion: Throw an exception instead? Maybe a specific, catchable exception that generates a
1842
            // @todo: error message to the user - dynamic item sets via itemProcFunc on check would be a bad idea anyway.
1843
            $value = $value & $maxV;
1844
        }
1845
        if ($field && $value > 0 && !empty($tcaFieldConf['eval'])) {
1846
            $evalCodesArray = GeneralUtility::trimExplode(',', $tcaFieldConf['eval'], true);
1847
            $otherRecordsWithSameValue = [];
1848
            $maxCheckedRecords = 0;
1849
            if (in_array('maximumRecordsCheckedInPid', $evalCodesArray, true)) {
1850
                $otherRecordsWithSameValue = $this->getRecordsWithSameValue($table, $id, $field, $value, $realPid);
1851
                $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsCheckedInPid'];
1852
            }
1853
            if (in_array('maximumRecordsChecked', $evalCodesArray, true)) {
1854
                $otherRecordsWithSameValue = $this->getRecordsWithSameValue($table, $id, $field, $value);
1855
                $maxCheckedRecords = (int)$tcaFieldConf['validation']['maximumRecordsChecked'];
1856
            }
1857
1858
            // there are more than enough records with value "1" in the DB
1859
            // if so, set this value to "0" again
1860
            if ($maxCheckedRecords && count($otherRecordsWithSameValue) >= $maxCheckedRecords) {
1861
                $value = 0;
1862
                $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]);
1863
            }
1864
        }
1865
        $res['value'] = $value;
1866
        return $res;
1867
    }
1868
1869
    /**
1870
     * Evaluates 'radio' type values.
1871
     *
1872
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1873
     * @param string $value The value to set.
1874
     * @param array $tcaFieldConf Field configuration from TCA
1875
     * @param string $table The table of the record
1876
     * @param int $id The id of the record
1877
     * @param int $pid The pid of the record
1878
     * @param string $field The field to check
1879
     * @return array Modified $res array
1880
     */
1881
    protected function checkValueForRadio($res, $value, $tcaFieldConf, $table, $id, $pid, $field)
1882
    {
1883
        if (is_array($tcaFieldConf['items'])) {
1884
            foreach ($tcaFieldConf['items'] as $set) {
1885
                if ((string)$set[1] === (string)$value) {
1886
                    $res['value'] = $value;
1887
                    break;
1888
                }
1889
            }
1890
        }
1891
1892
        // if no value was found and an itemsProcFunc is defined, check that for the value
1893
        if (!empty($tcaFieldConf['itemsProcFunc']) && empty($res['value'])) {
1894
            $processingService = GeneralUtility::makeInstance(ItemProcessingService::class);
1895
            $processedItems = $processingService->getProcessingItems(
1896
                $table,
1897
                $pid,
1898
                $field,
1899
                $this->checkValue_currentRecord,
1900
                $tcaFieldConf,
1901
                $tcaFieldConf['items']
1902
            );
1903
1904
            foreach ($processedItems as $set) {
1905
                if ((string)$set[1] === (string)$value) {
1906
                    $res['value'] = $value;
1907
                    break;
1908
                }
1909
            }
1910
        }
1911
1912
        return $res;
1913
    }
1914
1915
    /**
1916
     * Evaluates 'group' or 'select' type values.
1917
     *
1918
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
1919
     * @param string|array $value The value to set.
1920
     * @param array $tcaFieldConf Field configuration from TCA
1921
     * @param string $table Table name
1922
     * @param int $id UID of record
1923
     * @param mixed $curValue Current value of the field
1924
     * @param string $status 'update' or 'new' flag
1925
     * @param string $recFID Field identifier [table:uid:field] for flexforms
1926
     * @param array $uploadedFiles
1927
     * @param string $field Field name
1928
     * @return array Modified $res array
1929
     */
1930
    protected function checkValueForGroupSelect($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $recFID, $uploadedFiles, $field)
1931
    {
1932
        // Detecting if value sent is an array and if so, implode it around a comma:
1933
        if (is_array($value)) {
1934
            $value = implode(',', $value);
1935
        }
1936
        // 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.
1937
        // Anyway, this should NOT disturb anything else:
1938
        $value = $this->convNumEntityToByteValue($value);
1939
        // When values are sent as group or select they come as comma-separated values which are exploded by this function:
1940
        $valueArray = $this->checkValue_group_select_explodeSelectGroupValue($value);
1941
        // If multiple is not set, remove duplicates:
1942
        if (!($tcaFieldConf['multiple'] ?? false)) {
1943
            $valueArray = array_unique($valueArray);
1944
        }
1945
        // If an exclusive key is found, discard all others:
1946
        if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['exclusiveKeys'] ?? false)) {
1947
            $exclusiveKeys = GeneralUtility::trimExplode(',', $tcaFieldConf['exclusiveKeys']);
1948
            foreach ($valueArray as $index => $key) {
1949
                if (in_array($key, $exclusiveKeys, true)) {
1950
                    $valueArray = [$index => $key];
1951
                    break;
1952
                }
1953
            }
1954
        }
1955
        // 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?)
1956
        // 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!!
1957
        $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray);
1958
        // Checking for select / authMode, removing elements from $valueArray if any of them is not allowed!
1959
        if ($tcaFieldConf['type'] === 'select' && ($tcaFieldConf['authMode'] ?? false)) {
1960
            $preCount = count($valueArray);
1961
            foreach ($valueArray as $index => $key) {
1962
                if (!$this->BE_USER->checkAuthMode($table, $field, $key, $tcaFieldConf['authMode'])) {
1963
                    unset($valueArray[$index]);
1964
                }
1965
            }
1966
            // 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.
1967
            if ($preCount && empty($valueArray)) {
1968
                return [];
1969
            }
1970
        }
1971
        // For select types which has a foreign table attached:
1972
        $unsetResult = false;
1973
        if (
1974
            $tcaFieldConf['type'] === 'group' && $tcaFieldConf['internal_type'] === 'db'
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: ($tcaFieldConf['type'] =...ecial'] === 'languages', Probably Intended Meaning: $tcaFieldConf['type'] ==...cial'] === 'languages')
Loading history...
1975
            || $tcaFieldConf['type'] === 'select' && (($tcaFieldConf['foreign_table'] ?? false) || isset($tcaFieldConf['special']) && $tcaFieldConf['special'] === 'languages')
1976
        ) {
1977
            // check, if there is a NEW... id in the value, that should be substituted later
1978
            if (strpos($value, 'NEW') !== false) {
1979
                $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
1980
                $this->addNewValuesToRemapStackChildIds($valueArray);
1981
                $this->remapStack[] = [
1982
                    'func' => 'checkValue_group_select_processDBdata',
1983
                    'args' => [$valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field],
1984
                    'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 5],
1985
                    'field' => $field
1986
                ];
1987
                $unsetResult = true;
1988
            } else {
1989
                $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $tcaFieldConf['type'], $table, $field);
1990
            }
1991
        }
1992
        if (!$unsetResult) {
1993
            $newVal = $this->checkValue_checkMax($tcaFieldConf, $valueArray);
1994
            $res['value'] = $this->castReferenceValue(implode(',', $newVal), $tcaFieldConf);
1995
        } else {
1996
            unset($res['value']);
1997
        }
1998
        return $res;
1999
    }
2000
2001
    /**
2002
     * Applies the filter methods from a column's TCA configuration to a value array.
2003
     *
2004
     * @param array $tcaFieldConfiguration
2005
     * @param array $values
2006
     * @return array|mixed
2007
     * @throws \RuntimeException
2008
     */
2009
    protected function applyFiltersToValues(array $tcaFieldConfiguration, array $values)
2010
    {
2011
        if (empty($tcaFieldConfiguration['filter']) || !is_array($tcaFieldConfiguration['filter'])) {
2012
            return $values;
2013
        }
2014
        foreach ($tcaFieldConfiguration['filter'] as $filter) {
2015
            if (empty($filter['userFunc'])) {
2016
                continue;
2017
            }
2018
            $parameters = $filter['parameters'] ?: [];
2019
            $parameters['values'] = $values;
2020
            $parameters['tcaFieldConfig'] = $tcaFieldConfiguration;
2021
            $values = GeneralUtility::callUserFunction($filter['userFunc'], $parameters, $this);
2022
            if (!is_array($values)) {
2023
                throw new \RuntimeException('Failed calling filter userFunc.', 1336051942);
2024
            }
2025
        }
2026
        return $values;
2027
    }
2028
2029
    /**
2030
     * Evaluates 'flex' type values.
2031
     *
2032
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
2033
     * @param string|array $value The value to set.
2034
     * @param array $tcaFieldConf Field configuration from TCA
2035
     * @param string $table Table name
2036
     * @param int $id UID of record
2037
     * @param mixed $curValue Current value of the field
2038
     * @param string $status 'update' or 'new' flag
2039
     * @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.
2040
     * @param string $recFID Field identifier [table:uid:field] for flexforms
2041
     * @param int $tscPID TSconfig PID
2042
     * @param array $uploadedFiles Uploaded files for the field
2043
     * @param string $field Field name
2044
     * @return array Modified $res array
2045
     */
2046
    protected function checkValueForFlex($res, $value, $tcaFieldConf, $table, $id, $curValue, $status, $realPid, $recFID, $tscPID, $uploadedFiles, $field)
2047
    {
2048
        if (is_array($value)) {
2049
            // This value is necessary for flex form processing to happen on flexform fields in page records when they are copied.
2050
            // Problem: when copying a page, flexform XML comes along in the array for the new record - but since $this->checkValue_currentRecord
2051
            // does not have a uid or pid for that sake, the FlexFormTools->getDataStructureIdentifier() function returns no good DS. For new
2052
            // records we do know the expected PID so therefore we send that with this special parameter. Only active when larger than zero.
2053
            $row = $this->checkValue_currentRecord;
2054
            if ($status === 'new') {
2055
                $row['pid'] = $realPid;
2056
            }
2057
2058
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
2059
2060
            // Get data structure. The methods may throw various exceptions, with some of them being
2061
            // ok in certain scenarios, for instance on new record rows. Those are ok to "eat" here
2062
            // and substitute with a dummy DS.
2063
            $dataStructureArray = ['sheets' => ['sDEF' => []]];
2064
            try {
2065
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
2066
                    ['config' => $tcaFieldConf],
2067
                    $table,
2068
                    $field,
2069
                    $row
2070
                );
2071
2072
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
2073
            } 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...
2074
            }
2075
2076
            // Get current value array:
2077
            $currentValueArray = (string)$curValue !== '' ? GeneralUtility::xml2array($curValue) : [];
2078
            if (!is_array($currentValueArray)) {
2079
                $currentValueArray = [];
2080
            }
2081
            // Remove all old meta for languages...
2082
            // Evaluation of input values:
2083
            $value['data'] = $this->checkValue_flex_procInData($value['data'] ?? [], $currentValueArray['data'] ?? [], $uploadedFiles['data'] ?? [], $dataStructureArray, [$table, $id, $curValue, $status, $realPid, $recFID, $tscPID]);
2084
            // Create XML from input value:
2085
            $xmlValue = $this->checkValue_flexArray2Xml($value, true);
2086
2087
            // 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
2088
            // (provided that the current value was already stored IN the charset that the new value is converted to).
2089
            $arrValue = GeneralUtility::xml2array($xmlValue);
2090
2091
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkFlexFormValue'] ?? [] as $className) {
2092
                $hookObject = GeneralUtility::makeInstance($className);
2093
                if (method_exists($hookObject, 'checkFlexFormValue_beforeMerge')) {
2094
                    $hookObject->checkFlexFormValue_beforeMerge($this, $currentValueArray, $arrValue);
2095
                }
2096
            }
2097
2098
            ArrayUtility::mergeRecursiveWithOverrule($currentValueArray, $arrValue);
2099
            $xmlValue = $this->checkValue_flexArray2Xml($currentValueArray, true);
2100
2101
            // Action commands (sorting order and removals of elements) for flexform sections,
2102
            // see FormEngine for the use of this GP parameter
2103
            $actionCMDs = GeneralUtility::_GP('_ACTION_FLEX_FORMdata');
2104
            if (is_array($actionCMDs[$table][$id][$field]['data'] ?? null)) {
2105
                $arrValue = GeneralUtility::xml2array($xmlValue);
2106
                $this->_ACTION_FLEX_FORMdata($arrValue['data'], $actionCMDs[$table][$id][$field]['data']);
2107
                $xmlValue = $this->checkValue_flexArray2Xml($arrValue, true);
2108
            }
2109
            // Create the value XML:
2110
            $res['value'] = '';
2111
            $res['value'] .= $xmlValue;
2112
        } else {
2113
            // Passthrough...:
2114
            $res['value'] = $value;
2115
        }
2116
2117
        return $res;
2118
    }
2119
2120
    /**
2121
     * Converts an array to FlexForm XML
2122
     *
2123
     * @param array $array Array with FlexForm data
2124
     * @param bool $addPrologue If set, the XML prologue is returned as well.
2125
     * @return string Input array converted to XML
2126
     * @internal should only be used from within DataHandler
2127
     */
2128
    public function checkValue_flexArray2Xml($array, $addPrologue = false)
2129
    {
2130
        /** @var FlexFormTools $flexObj */
2131
        $flexObj = GeneralUtility::makeInstance(FlexFormTools::class);
2132
        return $flexObj->flexArray2Xml($array, $addPrologue);
2133
    }
2134
2135
    /**
2136
     * Actions for flex form element (move, delete)
2137
     * allows to remove and move flexform sections
2138
     *
2139
     * @param array $valueArray by reference
2140
     * @param array $actionCMDs
2141
     */
2142
    protected function _ACTION_FLEX_FORMdata(&$valueArray, $actionCMDs)
2143
    {
2144
        if (!is_array($valueArray) || !is_array($actionCMDs)) {
0 ignored issues
show
introduced by
The condition is_array($actionCMDs) is always true.
Loading history...
introduced by
The condition is_array($valueArray) is always true.
Loading history...
2145
            return;
2146
        }
2147
2148
        foreach ($actionCMDs as $key => $value) {
2149
            if ($key === '_ACTION') {
2150
                // First, check if there are "commands":
2151
                if (empty(array_filter($actionCMDs[$key]))) {
2152
                    continue;
2153
                }
2154
2155
                asort($actionCMDs[$key]);
2156
                $newValueArray = [];
2157
                foreach ($actionCMDs[$key] as $idx => $order) {
2158
                    // Just one reflection here: It is clear that when removing elements from a flexform, then we will get lost
2159
                    // files unless we act on this delete operation by traversing and deleting files that were referred to.
2160
                    if ($order !== 'DELETE') {
2161
                        $newValueArray[$idx] = $valueArray[$idx];
2162
                    }
2163
                    unset($valueArray[$idx]);
2164
                }
2165
                $valueArray += $newValueArray;
2166
            } elseif (is_array($actionCMDs[$key]) && isset($valueArray[$key])) {
2167
                $this->_ACTION_FLEX_FORMdata($valueArray[$key], $actionCMDs[$key]);
2168
            }
2169
        }
2170
    }
2171
2172
    /**
2173
     * Evaluates 'inline' type values.
2174
     * (partly copied from the select_group function on this issue)
2175
     *
2176
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
2177
     * @param string $value The value to set.
2178
     * @param array $tcaFieldConf Field configuration from TCA
2179
     * @param array $PP Additional parameters in a numeric array: $table,$id,$curValue,$status,$realPid,$recFID
2180
     * @param string $field Field name
2181
     * @param array $additionalData Additional data to be forwarded to sub-processors
2182
     * @internal should only be used from within DataHandler
2183
     */
2184
    public function checkValue_inline($res, $value, $tcaFieldConf, $PP, $field, array $additionalData = null)
2185
    {
2186
        [$table, $id, , $status] = $PP;
2187
        $this->checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, $additionalData);
2188
    }
2189
2190
    /**
2191
     * Evaluates 'inline' type values.
2192
     * (partly copied from the select_group function on this issue)
2193
     *
2194
     * @param array $res The result array. The processed value (if any!) is set in the 'value' key.
2195
     * @param string $value The value to set.
2196
     * @param array $tcaFieldConf Field configuration from TCA
2197
     * @param string $table Table name
2198
     * @param int $id UID of record
2199
     * @param string $status 'update' or 'new' flag
2200
     * @param string $field Field name
2201
     * @param array $additionalData Additional data to be forwarded to sub-processors
2202
     * @return array|false Modified $res array
2203
     * @internal should only be used from within DataHandler
2204
     */
2205
    public function checkValueForInline($res, $value, $tcaFieldConf, $table, $id, $status, $field, array $additionalData = null)
2206
    {
2207
        if (!$tcaFieldConf['foreign_table']) {
2208
            // Fatal error, inline fields should always have a foreign_table defined
2209
            return false;
2210
        }
2211
        // When values are sent they come as comma-separated values which are exploded by this function:
2212
        $valueArray = GeneralUtility::trimExplode(',', $value);
2213
        // Remove duplicates: (should not be needed)
2214
        $valueArray = array_unique($valueArray);
2215
        // Example for received data:
2216
        // $value = 45,NEW4555fdf59d154,12,123
2217
        // We need to decide whether we use the stack or can save the relation directly.
2218
        if (!empty($value) && (strpos($value, 'NEW') !== false || !MathUtility::canBeInterpretedAsInteger($id))) {
2219
            $this->remapStackRecords[$table][$id] = ['remapStackIndex' => count($this->remapStack)];
2220
            $this->addNewValuesToRemapStackChildIds($valueArray);
2221
            $this->remapStack[] = [
2222
                'func' => 'checkValue_inline_processDBdata',
2223
                'args' => [$valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData],
2224
                'pos' => ['valueArray' => 0, 'tcaFieldConf' => 1, 'id' => 2, 'table' => 4],
2225
                'additionalData' => $additionalData,
2226
                'field' => $field,
2227
            ];
2228
            unset($res['value']);
2229
        } elseif ($value || MathUtility::canBeInterpretedAsInteger($id)) {
2230
            $res['value'] = $this->checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field, $additionalData);
2231
        }
2232
        return $res;
2233
    }
2234
2235
    /**
2236
     * Checks if a fields has more items than defined via TCA in maxitems.
2237
     * If there are more items than allowed, the item list is truncated to the defined number.
2238
     *
2239
     * @param array $tcaFieldConf Field configuration from TCA
2240
     * @param array $valueArray Current value array of items
2241
     * @return array The truncated value array of items
2242
     * @internal should only be used from within DataHandler
2243
     */
2244
    public function checkValue_checkMax($tcaFieldConf, $valueArray)
2245
    {
2246
        // BTW, checking for min and max items here does NOT make any sense when MM is used because the above function
2247
        // calls will just return an array with a single item (the count) if MM is used... Why didn't I perform the check
2248
        // before? Probably because we could not evaluate the validity of record uids etc... Hmm...
2249
        // NOTE to the comment: It's not really possible to check for too few items, because you must then determine first,
2250
        // if the field is actual used regarding the CType.
2251
        $maxitems = isset($tcaFieldConf['maxitems']) ? (int)$tcaFieldConf['maxitems'] : 99999;
2252
        return array_slice($valueArray, 0, $maxitems);
2253
    }
2254
2255
    /*********************************************
2256
     *
2257
     * Helper functions for evaluation functions.
2258
     *
2259
     ********************************************/
2260
    /**
2261
     * Gets a unique value for $table/$id/$field based on $value
2262
     *
2263
     * @param string $table Table name
2264
     * @param string $field Field name for which $value must be unique
2265
     * @param string $value Value string.
2266
     * @param int $id UID to filter out in the lookup (the record itself...)
2267
     * @param int $newPid If set, the value will be unique for this PID
2268
     * @return string Modified value (if not-unique). Will be the value appended with a number (until 100, then the function just breaks).
2269
     * @todo: consider workspaces, especially when publishing a unique value which has a unique value already in live
2270
     * @internal should only be used from within DataHandler
2271
     */
2272
    public function getUnique($table, $field, $value, $id, $newPid = 0)
2273
    {
2274
        if (!is_array($GLOBALS['TCA'][$table]) || !is_array($GLOBALS['TCA'][$table]['columns'][$field])) {
2275
            // Field is not configured in TCA
2276
            return $value;
2277
        }
2278
2279
        if ((string)$GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] === 'exclude') {
2280
            $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
2281
            $l10nParent = (int)$this->checkValue_currentRecord[$transOrigPointerField];
2282
            if ($l10nParent > 0) {
2283
                // Current record is a translation and l10n_mode "exclude" just copies the value from source language
2284
                return $value;
2285
            }
2286
        }
2287
2288
        $newValue = $originalValue = $value;
2289
        $statement = $this->getUniqueCountStatement($newValue, $table, $field, (int)$id, (int)$newPid);
2290
        // For as long as records with the test-value existing, try again (with incremented numbers appended)
2291
        if ($statement->fetchColumn()) {
2292
            for ($counter = 0; $counter <= 100; $counter++) {
2293
                $newValue = $value . $counter;
2294
                $statement->bindValue(1, $newValue);
2295
                $statement->execute();
2296
                if (!$statement->fetchColumn()) {
2297
                    break;
2298
                }
2299
            }
2300
        }
2301
2302
        if ($originalValue !== $newValue) {
2303
            $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);
2304
        }
2305
2306
        return $newValue;
2307
    }
2308
2309
    /**
2310
     * Gets the count of records for a unique field
2311
     *
2312
     * @param string $value The string value which should be unique
2313
     * @param string $table Table name
2314
     * @param string $field Field name for which $value must be unique
2315
     * @param int $uid UID to filter out in the lookup (the record itself...)
2316
     * @param int $pid If set, the value will be unique for this PID
2317
     * @return \Doctrine\DBAL\Driver\Statement Return the prepared statement to check uniqueness
2318
     */
2319
    protected function getUniqueCountStatement(
2320
        string $value,
2321
        string $table,
2322
        string $field,
2323
        int $uid,
2324
        int $pid
2325
    ): Statement {
2326
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
2327
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
2328
        $queryBuilder
2329
            ->count('uid')
2330
            ->from($table)
2331
            ->where(
2332
                $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value, \PDO::PARAM_STR)),
2333
                $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter($uid, \PDO::PARAM_INT))
2334
            );
2335
        // ignore translations of current record if field is configured with l10n_mode = "exclude"
2336
        if (($GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude'
2337
            && ($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? '') !== ''
2338
            && ($GLOBALS['TCA'][$table]['columns'][$field]['languageField'] ?? '') !== '') {
2339
            $queryBuilder
2340
                ->andWhere(
2341
                    $queryBuilder->expr()->orX(
2342
                    // records without l10n_parent must be taken into account (in any language)
2343
                        $queryBuilder->expr()->eq(
2344
                            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2345
                            $queryBuilder->createPositionalParameter(0, \PDO::PARAM_INT)
2346
                        ),
2347
                        // translations of other records must be taken into account
2348
                        $queryBuilder->expr()->neq(
2349
                            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2350
                            $queryBuilder->createPositionalParameter($uid, \PDO::PARAM_INT)
2351
                        )
2352
                    )
2353
                );
2354
        }
2355
        if ($pid !== 0) {
2356
            $queryBuilder->andWhere(
2357
                $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, \PDO::PARAM_INT))
2358
            );
2359
        } else {
2360
            // pid>=0 for versioning
2361
            $queryBuilder->andWhere(
2362
                $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, \PDO::PARAM_INT))
2363
            );
2364
        }
2365
        return $queryBuilder->execute();
2366
    }
2367
2368
    /**
2369
     * gets all records that have the same value in a field
2370
     * excluding the given uid
2371
     *
2372
     * @param string $tableName Table name
2373
     * @param int $uid UID to filter out in the lookup (the record itself...)
2374
     * @param string $fieldName Field name for which $value must be unique
2375
     * @param string $value Value string.
2376
     * @param int $pageId If set, the value will be unique for this PID
2377
     * @return array
2378
     * @internal should only be used from within DataHandler
2379
     */
2380
    public function getRecordsWithSameValue($tableName, $uid, $fieldName, $value, $pageId = 0)
2381
    {
2382
        $result = [];
2383
        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
2384
            return $result;
2385
        }
2386
2387
        $uid = (int)$uid;
2388
        $pageId = (int)$pageId;
2389
2390
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
2391
        $queryBuilder->getRestrictions()
2392
            ->removeAll()
2393
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
2394
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
2395
2396
        $queryBuilder->select('*')
2397
            ->from($tableName)
2398
            ->where(
2399
                $queryBuilder->expr()->eq(
2400
                    $fieldName,
2401
                    $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR)
2402
                ),
2403
                $queryBuilder->expr()->neq(
2404
                    'uid',
2405
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
2406
                )
2407
            );
2408
2409
        if ($pageId) {
2410
            $queryBuilder->andWhere(
2411
                $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT))
2412
            );
2413
        }
2414
2415
        $result = $queryBuilder->execute()->fetchAll();
2416
2417
        return $result;
2418
    }
2419
2420
    /**
2421
     * @param string $value The field value to be evaluated
2422
     * @param array $evalArray Array of evaluations to traverse.
2423
     * @param string $is_in The "is_in" value of the field configuration from TCA
2424
     * @return array
2425
     * @internal should only be used from within DataHandler
2426
     */
2427
    public function checkValue_text_Eval($value, $evalArray, $is_in)
2428
    {
2429
        $res = [];
2430
        $set = true;
2431
        foreach ($evalArray as $func) {
2432
            switch ($func) {
2433
                case 'trim':
2434
                    $value = trim($value);
2435
                    break;
2436
                case 'required':
2437
                    if (!$value) {
2438
                        $set = false;
2439
                    }
2440
                    break;
2441
                default:
2442
                    if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2443
                        if (class_exists($func)) {
2444
                            $evalObj = GeneralUtility::makeInstance($func);
2445
                            if (method_exists($evalObj, 'evaluateFieldValue')) {
2446
                                $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2447
                            }
2448
                        }
2449
                    }
2450
            }
2451
        }
2452
        if ($set) {
2453
            $res['value'] = $value;
2454
        }
2455
        return $res;
2456
    }
2457
2458
    /**
2459
     * Evaluation of 'input'-type values based on 'eval' list
2460
     *
2461
     * @param string $value Value to evaluate
2462
     * @param array $evalArray Array of evaluations to traverse.
2463
     * @param string $is_in Is-in string for 'is_in' evaluation
2464
     * @param string $table Table name the eval is evaluated on
2465
     * @return array Modified $value in key 'value' or empty array
2466
     * @internal should only be used from within DataHandler
2467
     */
2468
    public function checkValue_input_Eval($value, $evalArray, $is_in, string $table = ''): array
2469
    {
2470
        $res = [];
2471
        $set = true;
2472
        foreach ($evalArray as $func) {
2473
            switch ($func) {
2474
                case 'int':
2475
                case 'year':
2476
                    $value = (int)$value;
2477
                    break;
2478
                case 'time':
2479
                case 'timesec':
2480
                    // If $value is a pure integer we have the number of seconds, we can store that directly
2481
                    if ($value !== '' && !MathUtility::canBeInterpretedAsInteger($value)) {
2482
                        // $value is an ISO 8601 date
2483
                        $value = (new \DateTime($value))->getTimestamp();
2484
                    }
2485
                    break;
2486
                case 'date':
2487
                case 'datetime':
2488
                    // If $value is a pure integer we have the number of seconds, we can store that directly
2489
                    if ($value !== null && $value !== '' && !MathUtility::canBeInterpretedAsInteger($value)) {
2490
                        // The value we receive from JS is an ISO 8601 date, which is always in UTC. (the JS code works like that, on purpose!)
2491
                        // For instance "1999-11-11T11:11:11Z"
2492
                        // Since the user actually specifies the time in the server's local time, we need to mangle this
2493
                        // to reflect the server TZ. So we make this 1999-11-11T11:11:11+0200 (assuming Europe/Vienna here)
2494
                        // In the database we store the date in UTC (1999-11-11T09:11:11Z), hence we take the timestamp of this converted value.
2495
                        // For achieving this we work with timestamps only (which are UTC) and simply adjust it for the
2496
                        // TZ difference.
2497
                        try {
2498
                            // Make the date from JS a timestamp
2499
                            $value = (new \DateTime($value))->getTimestamp();
2500
                        } catch (\Exception $e) {
2501
                            // set the default timezone value to achieve the value of 0 as a result
2502
                            $value = (int)date('Z', 0);
2503
                        }
2504
2505
                        // @todo this hacky part is problematic when it comes to times around DST switch! Add test to prove that this is broken.
2506
                        $value -= date('Z', $value);
2507
                    }
2508
                    break;
2509
                case 'double2':
2510
                    $value = preg_replace('/[^0-9,\\.-]/', '', $value);
2511
                    $negative = $value[0] === '-';
2512
                    $value = strtr($value, [',' => '.', '-' => '']);
2513
                    if (strpos($value, '.') === false) {
2514
                        $value .= '.0';
2515
                    }
2516
                    $valueArray = explode('.', $value);
2517
                    $dec = array_pop($valueArray);
2518
                    $value = implode('', $valueArray) . '.' . $dec;
2519
                    if ($negative) {
2520
                        $value *= -1;
2521
                    }
2522
                    $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

2522
                    $value = number_format(/** @scrutinizer ignore-type */ $value, 2, '.', '');
Loading history...
2523
                    break;
2524
                case 'md5':
2525
                    if (strlen($value) !== 32) {
2526
                        $set = false;
2527
                    }
2528
                    break;
2529
                case 'trim':
2530
                    $value = trim($value);
2531
                    break;
2532
                case 'upper':
2533
                    $value = mb_strtoupper($value, 'utf-8');
2534
                    break;
2535
                case 'lower':
2536
                    $value = mb_strtolower($value, 'utf-8');
2537
                    break;
2538
                case 'required':
2539
                    if (!isset($value) || $value === '') {
2540
                        $set = false;
2541
                    }
2542
                    break;
2543
                case 'is_in':
2544
                    $c = mb_strlen($value);
2545
                    if ($c) {
2546
                        $newVal = '';
2547
                        for ($a = 0; $a < $c; $a++) {
2548
                            $char = mb_substr($value, $a, 1);
2549
                            if (mb_strpos($is_in, $char) !== false) {
2550
                                $newVal .= $char;
2551
                            }
2552
                        }
2553
                        $value = $newVal;
2554
                    }
2555
                    break;
2556
                case 'nospace':
2557
                    $value = str_replace(' ', '', $value);
2558
                    break;
2559
                case 'alpha':
2560
                    $value = preg_replace('/[^a-zA-Z]/', '', $value);
2561
                    break;
2562
                case 'num':
2563
                    $value = preg_replace('/[^0-9]/', '', $value);
2564
                    break;
2565
                case 'alphanum':
2566
                    $value = preg_replace('/[^a-zA-Z0-9]/', '', $value);
2567
                    break;
2568
                case 'alphanum_x':
2569
                    $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
2570
                    break;
2571
                case 'domainname':
2572
                    if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) {
2573
                        $value = (string)HttpUtility::idn_to_ascii($value);
2574
                    }
2575
                    break;
2576
                case 'email':
2577
                    if ((string)$value !== '') {
2578
                        $this->checkValue_input_ValidateEmail($value, $set);
2579
                    }
2580
                    break;
2581
                case 'saltedPassword':
2582
                    // An incoming value is either the salted password if the user did not change existing password
2583
                    // when submitting the form, or a plaintext new password that needs to be turned into a salted password now.
2584
                    // The strategy is to see if a salt instance can be created from the incoming value. If so,
2585
                    // no new password was submitted and we keep the value. If no salting instance can be created,
2586
                    // incoming value must be a new plain text value that needs to be hashed.
2587
                    $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
2588
                    $mode = $table === 'fe_users' ? 'FE' : 'BE';
2589
                    try {
2590
                        $hashFactory->get($value, $mode);
2591
                    } catch (InvalidPasswordHashException $e) {
2592
                        // We got no salted password instance, incoming value must be a new plaintext password
2593
                        // Get an instance of the current configured salted password strategy and hash the value
2594
                        $newHashInstance = $hashFactory->getDefaultHashInstance($mode);
2595
                        $value = $newHashInstance->getHashedPassword($value);
2596
                    }
2597
                    break;
2598
                default:
2599
                    if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2600
                        if (class_exists($func)) {
2601
                            $evalObj = GeneralUtility::makeInstance($func);
2602
                            if (method_exists($evalObj, 'evaluateFieldValue')) {
2603
                                $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2604
                            }
2605
                        }
2606
                    }
2607
            }
2608
        }
2609
        if ($set) {
2610
            $res['value'] = $value;
2611
        }
2612
        return $res;
2613
    }
2614
2615
    /**
2616
     * If $value is not a valid e-mail address,
2617
     * $set will be set to false and a flash error
2618
     * message will be added
2619
     *
2620
     * @param string $value Value to evaluate
2621
     * @param bool $set TRUE if an update should be done
2622
     * @throws \InvalidArgumentException
2623
     * @throws \TYPO3\CMS\Core\Exception
2624
     */
2625
    protected function checkValue_input_ValidateEmail($value, &$set)
2626
    {
2627
        if (GeneralUtility::validEmail($value)) {
2628
            return;
2629
        }
2630
2631
        $set = false;
2632
        /** @var FlashMessage $message */
2633
        $message = GeneralUtility::makeInstance(
2634
            FlashMessage::class,
2635
            sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.invalidEmail'), $value),
2636
            '', // header is optional
2637
            FlashMessage::ERROR,
2638
            true // whether message should be stored in session
2639
        );
2640
        /** @var FlashMessageService $flashMessageService */
2641
        $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
2642
        $flashMessageService->getMessageQueueByIdentifier()->enqueue($message);
2643
    }
2644
2645
    /**
2646
     * Returns data for group/db and select fields
2647
     *
2648
     * @param array $valueArray Current value array
2649
     * @param array $tcaFieldConf TCA field config
2650
     * @param int $id Record id, used for look-up of MM relations (local_uid)
2651
     * @param string $status Status string ('update' or 'new')
2652
     * @param string $type The type, either 'select', 'group' or 'inline'
2653
     * @param string $currentTable Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler
2654
     * @param string $currentField field name, needs to be set for writing to sys_history
2655
     * @return array Modified value array
2656
     * @internal should only be used from within DataHandler
2657
     */
2658
    public function checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField)
2659
    {
2660
        if ($type === 'group') {
2661
            $tables = $tcaFieldConf['allowed'];
2662
        } elseif (!empty($tcaFieldConf['special']) && $tcaFieldConf['special'] === 'languages') {
2663
            $tables = 'sys_language';
2664
        } else {
2665
            $tables = $tcaFieldConf['foreign_table'];
2666
        }
2667
        $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : '';
2668
        $newRelations = implode(',', $valueArray);
2669
        /** @var RelationHandler $dbAnalysis */
2670
        $dbAnalysis = $this->createRelationHandlerInstance();
2671
        $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2672
        $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf);
2673
        if ($tcaFieldConf['MM'] ?? false) {
2674
            // convert submitted items to use version ids instead of live ids
2675
            // (only required for MM relations in a workspace context)
2676
            $dbAnalysis->convertItemArray();
2677
            if ($status === 'update') {
2678
                /** @var RelationHandler $oldRelations_dbAnalysis */
2679
                $oldRelations_dbAnalysis = $this->createRelationHandlerInstance();
2680
                $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2681
                // Db analysis with $id will initialize with the existing relations
2682
                $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf);
2683
                $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray());
2684
                $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

2684
                $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, /** @scrutinizer ignore-type */ $prep);
Loading history...
2685
                if ($oldRelations != $newRelations) {
2686
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
2687
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
2688
                } else {
2689
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
2690
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
2691
                }
2692
            } else {
2693
                $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
2694
            }
2695
            $valueArray = $dbAnalysis->countItems();
2696
        } else {
2697
            $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

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

3130
        $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields($table, $uid, /** @scrutinizer ignore-type */ abs($destPid), false) : [];
Loading history...
3131
        // Page TSconfig related:
3132
        $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
3133
        $tE = $this->getTableEntries($table, $TSConfig);
3134
        // Traverse ALL fields of the selected record:
3135
        foreach ($row as $field => $value) {
3136
            if (!in_array($field, $nonFields, true)) {
3137
                // Get TCA configuration for the field:
3138
                $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? [];
3139
                // Preparation/Processing of the value:
3140
                // "pid" is hardcoded of course:
3141
                // isset() won't work here, since values can be NULL in each of the arrays
3142
                // except setDefaultOnCopyArray, since we exploded that from a string
3143
                if ($field === 'pid') {
3144
                    $value = $destPid;
3145
                } elseif (array_key_exists($field, $overrideValues)) {
3146
                    // Override value...
3147
                    $value = $overrideValues[$field];
3148
                } elseif (array_key_exists($field, $copyAfterFields)) {
3149
                    // Copy-after value if available:
3150
                    $value = $copyAfterFields[$field];
3151
                } else {
3152
                    // Hide at copy may override:
3153
                    if ($first && $field == $enableField
3154
                        && ($GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
3155
                        && !$this->neverHideAtCopy
3156
                        && !($tE['disableHideAtCopy'] ?? false)
3157
                    ) {
3158
                        $value = 1;
3159
                    }
3160
                    // Prepend label on copy:
3161
                    if ($first && $field == $headerField
3162
                        && ($GLOBALS['TCA'][$table]['ctrl']['prependAtCopy'] ?? false)
3163
                        && !($tE['disablePrependAtCopy'] ?? false)
3164
                    ) {
3165
                        $value = $this->getCopyHeader($table, $this->resolvePid($table, $destPid), $field, $this->clearPrefixFromValue($table, $value), 0);
3166
                    }
3167
                    // Processing based on the TCA config field type (files, references, flexforms...)
3168
                    $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $tscPID, $language);
3169
                }
3170
                // Add value to array.
3171
                $data[$table][$theNewID][$field] = $value;
3172
            }
3173
        }
3174
        // Overriding values:
3175
        if ($GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
3176
            $data[$table][$theNewID][$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
3177
        }
3178
        // Setting original UID:
3179
        if ($GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? false) {
3180
            $data[$table][$theNewID][$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3181
        }
3182
        // Do the copy by simply submitting the array through DataHandler:
3183
        /** @var DataHandler $copyTCE */
3184
        $copyTCE = $this->getLocalTCE();
3185
        $copyTCE->start($data, [], $this->BE_USER);
3186
        $copyTCE->process_datamap();
3187
        // Getting the new UID:
3188
        $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID];
3189
        if ($theNewSQLID) {
3190
            $this->copyMappingArray[$table][$origUid] = $theNewSQLID;
3191
            // Keep automatically versionized record information:
3192
            if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) {
3193
                $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID];
3194
            }
3195
        }
3196
        $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3197
        unset($copyTCE);
3198
        if (!$ignoreLocalization && $language == 0) {
3199
            //repointing the new translation records to the parent record we just created
3200
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $theNewSQLID;
3201
            if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3202
                $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = 0;
3203
            }
3204
            $this->copyL10nOverlayRecords($table, $uid, $destPid, $first, $overrideValues, $excludeFields);
3205
        }
3206
3207
        return $theNewSQLID;
3208
    }
3209
3210
    /**
3211
     * Copying pages
3212
     * Main function for copying pages.
3213
     *
3214
     * @param int $uid Page UID to copy
3215
     * @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
3216
     * @internal should only be used from within DataHandler
3217
     */
3218
    public function copyPages($uid, $destPid)
3219
    {
3220
        // Initialize:
3221
        $uid = (int)$uid;
3222
        $destPid = (int)$destPid;
3223
3224
        $copyTablesAlongWithPage = $this->getAllowedTablesToCopyWhenCopyingAPage();
3225
        // Begin to copy pages if we're allowed to:
3226
        if ($this->admin || in_array('pages', $copyTablesAlongWithPage, true)) {
3227
            // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured)
3228
            // This method also copies the localizations of a page
3229
            $theNewRootID = $this->copySpecificPage($uid, $destPid, $copyTablesAlongWithPage, true);
3230
            // If we're going to copy recursively
3231
            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...
3232
                // Get ALL subpages to copy (read-permissions are respected!):
3233
                $CPtable = $this->int_pageTreeInfo([], $uid, (int)$this->copyTree, $theNewRootID);
3234
                // Now copying the subpages:
3235
                foreach ($CPtable as $thePageUid => $thePagePid) {
3236
                    $newPid = $this->copyMappingArray['pages'][$thePagePid];
3237
                    if (isset($newPid)) {
3238
                        $this->copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage);
3239
                    } else {
3240
                        $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch');
3241
                        break;
3242
                    }
3243
                }
3244
            }
3245
        } else {
3246
            $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page without permission to this table');
3247
        }
3248
    }
3249
3250
    /**
3251
     * Compile a list of tables that should be copied along when a page is about to be copied.
3252
     *
3253
     * First, get the list that the user is allowed to modify (all if admin),
3254
     * and then check against a possible limitation within "DataHandler->copyWhichTables" if not set to "*"
3255
     * to limit the list further down
3256
     *
3257
     * @return array
3258
     */
3259
    protected function getAllowedTablesToCopyWhenCopyingAPage(): array
3260
    {
3261
        // Finding list of tables to copy.
3262
        // These are the tables, the user may modify
3263
        $copyTablesArray = $this->admin ? $this->compileAdminTables() : explode(',', $this->BE_USER->groupData['tables_modify']);
3264
        // If not all tables are allowed then make a list of allowed tables.
3265
        // That is the tables that figure in both allowed tables AND the copyTable-list
3266
        if (strpos($this->copyWhichTables, '*') === false) {
3267
            $definedTablesToCopy = GeneralUtility::trimExplode(',', $this->copyWhichTables, true);
3268
            // Pages are always allowed
3269
            $definedTablesToCopy[] = 'pages';
3270
            $definedTablesToCopy = array_flip($definedTablesToCopy);
3271
            foreach ($copyTablesArray as $k => $table) {
3272
                if (!$table || !isset($definedTablesToCopy[$table])) {
3273
                    unset($copyTablesArray[$k]);
3274
                }
3275
            }
3276
        }
3277
        $copyTablesArray = array_unique($copyTablesArray);
3278
        return $copyTablesArray;
3279
    }
3280
    /**
3281
     * Copying a single page ($uid) to $destPid and all tables in the array copyTablesArray.
3282
     *
3283
     * @param int $uid Page uid
3284
     * @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
3285
     * @param array $copyTablesArray Table on pages to copy along with the page.
3286
     * @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
3287
     * @return int|null The id of the new page, if applicable.
3288
     * @internal should only be used from within DataHandler
3289
     */
3290
    public function copySpecificPage($uid, $destPid, $copyTablesArray, $first = false)
3291
    {
3292
        // Copy the page itself:
3293
        $theNewRootID = $this->copyRecord('pages', $uid, $destPid, $first);
3294
        $currentWorkspaceId = (int)$this->BE_USER->workspace;
3295
        // If a new page was created upon the copy operation we will proceed with all the tables ON that page:
3296
        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...
3297
            foreach ($copyTablesArray as $table) {
3298
                // All records under the page is copied.
3299
                if ($table && is_array($GLOBALS['TCA'][$table]) && $table !== 'pages') {
3300
                    $fields = ['uid'];
3301
                    $languageField = null;
3302
                    $transOrigPointerField = null;
3303
                    $translationSourceField = null;
3304
                    if (BackendUtility::isTableLocalizable($table)) {
3305
                        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
3306
                        $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3307
                        $fields[] = $languageField;
3308
                        $fields[] = $transOrigPointerField;
3309
                        if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3310
                            $translationSourceField = $GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3311
                            $fields[] = $translationSourceField;
3312
                        }
3313
                    }
3314
                    $isTableWorkspaceEnabled = BackendUtility::isTableWorkspaceEnabled($table);
3315
                    if ($isTableWorkspaceEnabled) {
3316
                        $fields[] = 't3ver_oid';
3317
                        $fields[] = 't3ver_state';
3318
                        $fields[] = 't3ver_wsid';
3319
                    }
3320
                    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3321
                    $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
3322
                    $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId));
3323
                    $queryBuilder
3324
                        ->select(...$fields)
3325
                        ->from($table)
3326
                        ->where(
3327
                            $queryBuilder->expr()->eq(
3328
                                'pid',
3329
                                $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
3330
                            )
3331
                        );
3332
                    if (!empty($GLOBALS['TCA'][$table]['ctrl']['sortby'])) {
3333
                        $queryBuilder->orderBy($GLOBALS['TCA'][$table]['ctrl']['sortby'], 'DESC');
3334
                    }
3335
                    $queryBuilder->addOrderBy('uid');
3336
                    try {
3337
                        $result = $queryBuilder->execute();
3338
                        $rows = [];
3339
                        $movedLiveIds = [];
3340
                        $movedLiveRecords = [];
3341
                        while ($row = $result->fetch()) {
3342
                            if ($isTableWorkspaceEnabled && (int)$row['t3ver_state'] === VersionState::MOVE_POINTER) {
3343
                                $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid'];
3344
                            }
3345
                            $rows[(int)$row['uid']] = $row;
3346
                        }
3347
                        // Resolve placeholders of workspace versions
3348
                        if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) {
3349
                            // If a record was moved within the page, the PlainDataResolver needs the moved record
3350
                            // but not the original live version, otherwise the moved record is not considered at all.
3351
                            // For this reason, we find the live ids, where there was also a moved record in the SQL
3352
                            // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver.
3353
                            // see changeContentSortingAndCopyDraftPage test
3354
                            foreach ($movedLiveIds as $liveId => $movePlaceHolderId) {
3355
                                if (isset($rows[$liveId])) {
3356
                                    $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId];
3357
                                    unset($rows[$liveId]);
3358
                                }
3359
                            }
3360
                            $rows = array_reverse(
3361
                                $this->resolveVersionedRecords(
3362
                                    $table,
3363
                                    implode(',', $fields),
3364
                                    $GLOBALS['TCA'][$table]['ctrl']['sortby'],
3365
                                    array_keys($rows)
3366
                                ),
3367
                                true
3368
                            );
3369
                            foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) {
3370
                                $rows[$movePlaceHolderId] = $liveRecord;
3371
                            }
3372
                        }
3373
                        if (is_array($rows)) {
3374
                            $languageSourceMap = [];
3375
                            $overrideValues = $translationSourceField ? [$translationSourceField => 0] : [];
3376
                            $doRemap = false;
3377
                            foreach ($rows as $row) {
3378
                                // Skip localized records that will be processed in
3379
                                // copyL10nOverlayRecords() on copying the default language record
3380
                                $transOrigPointer = $row[$transOrigPointerField];
3381
                                if ($row[$languageField] > 0 && $transOrigPointer > 0 && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer]))) {
3382
                                    continue;
3383
                                }
3384
                                // Copying each of the underlying records...
3385
                                $newUid = $this->copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues);
3386
                                if ($translationSourceField) {
3387
                                    $languageSourceMap[$row['uid']] = $newUid;
3388
                                    if ($row[$languageField] > 0) {
3389
                                        $doRemap = true;
3390
                                    }
3391
                                }
3392
                            }
3393
                            if ($doRemap) {
3394
                                //remap is needed for records in non-default language records in the "free mode"
3395
                                $this->copy_remapTranslationSourceField($table, $rows, $languageSourceMap);
3396
                            }
3397
                        }
3398
                    } catch (DBALException $e) {
3399
                        $databaseErrorMessage = $e->getPrevious()->getMessage();
3400
                        $this->log($table, $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: ' . $databaseErrorMessage);
3401
                    }
3402
                }
3403
            }
3404
            $this->processRemapStack();
3405
            return $theNewRootID;
3406
        }
3407
        return null;
3408
    }
3409
3410
    /**
3411
     * Copying records, but makes a "raw" copy of a record.
3412
     * Basically the only thing observed is field processing like the copying of files and correction of ids. All other fields are 1-1 copied.
3413
     * 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.
3414
     * 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!?
3415
     * This function is used to create new versions of a record.
3416
     * 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.
3417
     *
3418
     * @param string $table Element table
3419
     * @param int $uid Element UID
3420
     * @param int $pid Element PID (real PID, not checked)
3421
     * @param array $overrideArray Override array - must NOT contain any fields not in the table!
3422
     * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently
3423
     * @return int Returns the new ID of the record (if applicable)
3424
     * @internal should only be used from within DataHandler
3425
     */
3426
    public function copyRecord_raw($table, $uid, $pid, $overrideArray = [], array $workspaceOptions = [])
3427
    {
3428
        $uid = (int)$uid;
3429
        // Stop any actions if the record is marked to be deleted:
3430
        // (this can occur if IRRE elements are versionized and child elements are removed)
3431
        if ($this->isElementToBeDeleted($table, $uid)) {
3432
            return null;
3433
        }
3434
        // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before:
3435
        if (!$GLOBALS['TCA'][$table] || !$uid || $this->isRecordCopied($table, $uid)) {
3436
            return null;
3437
        }
3438
3439
        // Fetch record with permission check
3440
        $row = $this->recordInfoWithPermissionCheck($table, $uid, Permission::PAGE_SHOW);
3441
3442
        // This checks if the record can be selected which is all that a copy action requires.
3443
        if ($row === false) {
3444
            $this->log(
3445
                $table,
3446
                $uid,
3447
                SystemLogDatabaseAction::DELETE,
3448
                0,
3449
                SystemLogErrorClassification::USER_ERROR,
3450
                'Attempt to rawcopy/versionize record which either does not exist or you don\'t have permission to read'
3451
            );
3452
            return null;
3453
        }
3454
3455
        // Set up fields which should not be processed. They are still written - just passed through no-questions-asked!
3456
        $nonFields = ['uid', 'pid', 't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody'];
3457
3458
        // Merge in override array.
3459
        $row = array_merge($row, $overrideArray);
3460
        // Traverse ALL fields of the selected record:
3461
        foreach ($row as $field => $value) {
3462
            /** @var string $field */
3463
            if (!in_array($field, $nonFields, true)) {
3464
                // Get TCA configuration for the field:
3465
                $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? false;
3466
                if (is_array($conf)) {
3467
                    // Processing based on the TCA config field type (files, references, flexforms...)
3468
                    $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions);
3469
                }
3470
                // Add value to array.
3471
                $row[$field] = $value;
3472
            }
3473
        }
3474
        $row['pid'] = $pid;
3475
        // Setting original UID:
3476
        if ($GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? '') {
3477
            $row[$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3478
        }
3479
        // Do the copy by internal function
3480
        $theNewSQLID = $this->insertNewCopyVersion($table, $row, $pid);
3481
3482
        // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records
3483
        // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference
3484
        // for a eg. tt_content record is marked as deleted. The tt_content record then needs a reference index update.
3485
        // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now.
3486
        if (!empty($workspaceOptions)) {
3487
            $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$row['uid'], (int)$this->BE_USER->workspace);
3488
        }
3489
3490
        if ($theNewSQLID) {
3491
            $this->dbAnalysisStoreExec();
3492
            $this->dbAnalysisStore = [];
3493
            return $this->copyMappingArray[$table][$uid] = $theNewSQLID;
3494
        }
3495
        return null;
3496
    }
3497
3498
    /**
3499
     * Inserts a record in the database, passing TCA configuration values through checkValue() but otherwise does NOTHING and checks nothing regarding permissions.
3500
     * 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...
3501
     *
3502
     * @param string $table Table name
3503
     * @param array $fieldArray Field array to insert as a record
3504
     * @param int $realPid The value of PID field.
3505
     * @return int Returns the new ID of the record (if applicable)
3506
     * @internal should only be used from within DataHandler
3507
     */
3508
    public function insertNewCopyVersion($table, $fieldArray, $realPid)
3509
    {
3510
        $id = StringUtility::getUniqueId('NEW');
3511
        // $fieldArray is set as current record.
3512
        // 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...
3513
        $this->checkValue_currentRecord = $fieldArray;
3514
        // Makes sure that transformations aren't processed on the copy.
3515
        $backupDontProcessTransformations = $this->dontProcessTransformations;
3516
        $this->dontProcessTransformations = true;
3517
        // Traverse record and input-process each value:
3518
        foreach ($fieldArray as $field => $fieldValue) {
3519
            if (isset($GLOBALS['TCA'][$table]['columns'][$field])) {
3520
                // Evaluating the value.
3521
                $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
3522
                if (isset($res['value'])) {
3523
                    $fieldArray[$field] = $res['value'];
3524
                }
3525
            }
3526
        }
3527
        // System fields being set:
3528
        if ($GLOBALS['TCA'][$table]['ctrl']['crdate']) {
3529
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
3530
        }
3531
        if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id']) {
3532
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
3533
        }
3534
        if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
3535
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
3536
        }
3537
        // Finally, insert record:
3538
        $this->insertDB($table, $id, $fieldArray, true);
3539
        // Resets dontProcessTransformations to the previous state.
3540
        $this->dontProcessTransformations = $backupDontProcessTransformations;
3541
        // Return new id:
3542
        return $this->substNEWwithIDs[$id];
3543
    }
3544
3545
    /**
3546
     * Processing/Preparing content for copyRecord() function
3547
     *
3548
     * @param string $table Table name
3549
     * @param int $uid Record uid
3550
     * @param string $field Field name being processed
3551
     * @param string $value Input value to be processed.
3552
     * @param array $row Record array
3553
     * @param array $conf TCA field configuration
3554
     * @param int $realDestPid Real page id (pid) the record is copied to
3555
     * @param int $language Language ID (from sys_language table) used in the duplicated record
3556
     * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently
3557
     * @return array|string
3558
     * @internal
3559
     * @see copyRecord()
3560
     */
3561
    public function copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = [])
3562
    {
3563
        $inlineSubType = $this->getInlineFieldType($conf);
3564
        // Get the localization mode for the current (parent) record (keep|select):
3565
        // Register if there are references to take care of or MM is used on an inline field (no change to value):
3566
        if ($this->isReferenceField($conf) || $inlineSubType === 'mm') {
3567
            $value = $this->copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language);
3568
        } elseif ($inlineSubType !== false) {
3569
            $value = $this->copyRecord_processInline($table, $uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions);
3570
        }
3571
        // 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())
3572
        if (isset($conf['type']) && $conf['type'] === 'flex') {
3573
            // Get current value array:
3574
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
3575
            $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
3576
                ['config' => $conf],
3577
                $table,
3578
                $field,
3579
                $row
3580
            );
3581
            $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
3582
            $currentValueArray = GeneralUtility::xml2array($value);
3583
            // Traversing the XML structure, processing files:
3584
            if (is_array($currentValueArray)) {
3585
                $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $uid, $field, $realDestPid], 'copyRecord_flexFormCallBack', $workspaceOptions);
3586
                // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created.
3587
                $value = $currentValueArray;
3588
            }
3589
        }
3590
        return $value;
3591
    }
3592
3593
    /**
3594
     * Processes the children of an MM relation field (select, group, inline) when the parent record is copied.
3595
     *
3596
     * @param string $table
3597
     * @param int $uid
3598
     * @param string $field
3599
     * @param string $value
3600
     * @param array $conf
3601
     * @param int $language
3602
     * @return string
3603
     */
3604
    protected function copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language)
3605
    {
3606
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
3607
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
3608
        $mmTable = isset($conf['MM']) && $conf['MM'] ? $conf['MM'] : '';
3609
        $localizeForeignTable = isset($conf['foreign_table']) && BackendUtility::isTableLocalizable($conf['foreign_table']);
3610
        // Localize referenced records of select fields:
3611
        $localizingNonManyToManyFieldReferences = empty($mmTable) && $localizeForeignTable && isset($conf['localizeReferencesAtParentLocalization']) && $conf['localizeReferencesAtParentLocalization'];
3612
        /** @var RelationHandler $dbAnalysis */
3613
        $dbAnalysis = $this->createRelationHandlerInstance();
3614
        $dbAnalysis->start($value, $allowedTables, $mmTable, $uid, $table, $conf);
3615
        $purgeItems = false;
3616
        if ($language > 0 && $localizingNonManyToManyFieldReferences) {
3617
            foreach ($dbAnalysis->itemArray as $index => $item) {
3618
                // Since select fields can reference many records, check whether there's already a localization:
3619
                $recordLocalization = BackendUtility::getRecordLocalization($item['table'], $item['id'], $language);
3620
                if ($recordLocalization) {
3621
                    $dbAnalysis->itemArray[$index]['id'] = $recordLocalization[0]['uid'];
3622
                } elseif ($this->isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . (string)$language) === false) {
3623
                    $dbAnalysis->itemArray[$index]['id'] = $this->localize($item['table'], $item['id'], $language);
3624
                }
3625
            }
3626
            $purgeItems = true;
3627
        }
3628
3629
        if ($purgeItems || $mmTable) {
3630
            $dbAnalysis->purgeItemArray();
3631
            $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

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

4239
                $queryBuilder->setParameter('pointer', abs(/** @scrutinizer ignore-type */ $originalRecordDestinationPid), \PDO::PARAM_INT);
Loading history...
4240
                $destL10nRecords = $queryBuilder->execute()->fetchAll();
4241
                // Index the localized record uids by language
4242
                if (is_array($destL10nRecords)) {
4243
                    foreach ($destL10nRecords as $record) {
4244
                        $localizedDestPids[$record[$GLOBALS['TCA'][$table]['ctrl']['languageField']]] = -$record['uid'];
4245
                    }
4246
                }
4247
            }
4248
            // Move the localized records after the corresponding localizations of the destination record
4249
            foreach ($l10nRecords as $record) {
4250
                $localizedDestPid = (int)$localizedDestPids[$record[$GLOBALS['TCA'][$table]['ctrl']['languageField']]];
4251
                if ($localizedDestPid < 0) {
4252
                    $this->moveRecord($table, $record['uid'], $localizedDestPid);
4253
                } else {
4254
                    $this->moveRecord($table, $record['uid'], $destPid);
4255
                }
4256
            }
4257
        }
4258
    }
4259
4260
    /**
4261
     * Localizes a record to another system language
4262
     *
4263
     * @param string $table Table name
4264
     * @param int $uid Record uid (to be localized)
4265
     * @param int $language Language ID (from sys_language table)
4266
     * @return int|bool The uid (int) of the new translated record or FALSE (bool) if something went wrong
4267
     * @internal should only be used from within DataHandler
4268
     */
4269
    public function localize($table, $uid, $language)
4270
    {
4271
        $newId = false;
4272
        $uid = (int)$uid;
4273
        if (!$GLOBALS['TCA'][$table] || !$uid || $this->isNestedElementCallRegistered($table, $uid, 'localize-' . (string)$language) !== false) {
4274
            return false;
4275
        }
4276
4277
        $this->registerNestedElementCall($table, $uid, 'localize-' . (string)$language);
4278
        if (!$GLOBALS['TCA'][$table]['ctrl']['languageField'] || !$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']) {
4279
            $this->newlog('Localization failed; "languageField" and "transOrigPointerField" must be defined for the table ' . $table, SystemLogErrorClassification::USER_ERROR);
4280
            return false;
4281
        }
4282
        $langRec = BackendUtility::getRecord('sys_language', (int)$language, 'uid,title');
4283
        if (!$langRec) {
4284
            $this->newlog('Sys language UID "' . $language . '" not found valid!', SystemLogErrorClassification::USER_ERROR);
4285
            return false;
4286
        }
4287
4288
        if (!$this->doesRecordExist($table, $uid, Permission::PAGE_SHOW)) {
4289
            $this->newlog('Attempt to localize record ' . $table . ':' . $uid . ' without permission.', SystemLogErrorClassification::USER_ERROR);
4290
            return false;
4291
        }
4292
4293
        // Getting workspace overlay if possible - this will localize versions in workspace if any
4294
        $row = BackendUtility::getRecordWSOL($table, $uid);
4295
        if (!is_array($row)) {
0 ignored issues
show
introduced by
The condition is_array($row) is always true.
Loading history...
4296
            $this->newlog('Attempt to localize record ' . $table . ':' . $uid . ' that did not exist!', SystemLogErrorClassification::USER_ERROR);
4297
            return false;
4298
        }
4299
4300
        // Make sure that records which are translated from another language than the default language have a correct
4301
        // localization source set themselves, before translating them to another language.
4302
        if ((int)$row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4303
            && $row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0) {
4304
            $localizationParentRecord = BackendUtility::getRecord(
4305
                $table,
4306
                $row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]
4307
            );
4308
            if ((int)$localizationParentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']] !== 0) {
4309
                $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);
4310
                return false;
4311
            }
4312
        }
4313
4314
        // Default language records must never have a localization parent as they are the origin of any translation.
4315
        if ((int)$row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4316
            && (int)$row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4317
            $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);
4318
            return false;
4319
        }
4320
4321
        $recordLocalizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'AND pid=' . (int)$row['pid']);
4322
4323
        if (!empty($recordLocalizations)) {
4324
            $this->newlog(sprintf(
4325
                'Localization failed: there already are localizations (%s) for language %d of the "%s" record %d!',
4326
                implode(', ', array_column($recordLocalizations, 'uid')),
4327
                $language,
4328
                $table,
4329
                $uid
4330
            ), 1);
4331
            return false;
4332
        }
4333
4334
        // Initialize:
4335
        $overrideValues = [];
4336
        // Set override values:
4337
        $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['languageField']] = $langRec['uid'];
4338
        // If the translated record is a default language record, set it's uid as localization parent of the new record.
4339
        // If translating from any other language, no override is needed; we just can copy the localization parent of
4340
        // the original record (which is pointing to the correspondent default language record) to the new record.
4341
        // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case.
4342
        // For pages, there is no "copy/free mode".
4343
        if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4344
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $uid;
4345
        } elseif (!$this->useTransOrigPointerField) {
4346
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = 0;
4347
        }
4348
        if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
4349
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = $uid;
4350
        }
4351
        // Copy the type (if defined in both tables) from the original record so that translation has same type as original record
4352
        if (isset($GLOBALS['TCA'][$table]['ctrl']['type'])) {
4353
            // @todo: Possible bug here? type can be something like 'table:field', which is then null in $row, writing null to $overrideValues
4354
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['type']] = $row[$GLOBALS['TCA'][$table]['ctrl']['type']] ?? null;
4355
        }
4356
        // Set exclude Fields:
4357
        foreach ($GLOBALS['TCA'][$table]['columns'] as $fN => $fCfg) {
4358
            $translateToMsg = '';
4359
            // Check if we are just prefixing:
4360
            if (isset($fCfg['l10n_mode']) && $fCfg['l10n_mode'] === 'prefixLangTitle') {
4361
                if (($fCfg['config']['type'] === 'text' || $fCfg['config']['type'] === 'input') && (string)$row[$fN] !== '') {
4362
                    [$tscPID] = BackendUtility::getTSCpid($table, $uid, '');
4363
                    $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
4364
                    $tE = $this->getTableEntries($table, $TSConfig);
4365
                    if (!empty($TSConfig['translateToMessage']) && !($tE['disablePrependAtCopy'] ?? false)) {
4366
                        $translateToMsg = $this->getLanguageService()->sL($TSConfig['translateToMessage']);
4367
                        $translateToMsg = @sprintf($translateToMsg, $langRec['title']);
4368
                    }
4369
4370
                    foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) {
4371
                        $hookObj = GeneralUtility::makeInstance($className);
4372
                        if (method_exists($hookObj, 'processTranslateTo_copyAction')) {
4373
                            $hookObj->processTranslateTo_copyAction($row[$fN], $langRec, $this, $fN);
4374
                        }
4375
                    }
4376
                    if (!empty($translateToMsg)) {
4377
                        $overrideValues[$fN] = '[' . $translateToMsg . '] ' . $row[$fN];
4378
                    } else {
4379
                        $overrideValues[$fN] = $row[$fN];
4380
                    }
4381
                }
4382
            }
4383
        }
4384
4385
        if ($table !== 'pages') {
4386
            // Get the uid of record after which this localized record should be inserted
4387
            $previousUid = $this->getPreviousLocalizedRecordUid($table, $uid, $row['pid'], $language);
4388
            // Execute the copy:
4389
            $newId = $this->copyRecord($table, $uid, -$previousUid, true, $overrideValues, '', $language);
4390
        } else {
4391
            // Create new page which needs to contain the same pid as the original page
4392
            $overrideValues['pid'] = $row['pid'];
4393
            // Take over the hidden state of the original language state, this is done due to legacy reasons where-as
4394
            // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1"
4395
            if (!empty($GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'])) {
4396
                $hiddenFieldName = $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'];
4397
                $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? $GLOBALS['TCA'][$table]['columns'][$hiddenFieldName]['config']['default'];
4398
            }
4399
            $temporaryId = StringUtility::getUniqueId('NEW');
4400
            $copyTCE = $this->getLocalTCE();
4401
            $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER);
4402
            $copyTCE->process_datamap();
4403
            // Getting the new UID as if it had been copied:
4404
            $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId];
4405
            if ($theNewSQLID) {
4406
                $this->copyMappingArray[$table][$uid] = $theNewSQLID;
4407
                $newId = $theNewSQLID;
4408
            }
4409
        }
4410
4411
        return $newId;
4412
    }
4413
4414
    /**
4415
     * Performs localization or synchronization of child records.
4416
     * The $command argument expects an array, but supports a string for backward-compatibility.
4417
     *
4418
     * $command = array(
4419
     *   'field' => 'tx_myfieldname',
4420
     *   'language' => 2,
4421
     *   // either the key 'action' or 'ids' must be set
4422
     *   'action' => 'synchronize', // or 'localize'
4423
     *   'ids' => array(1, 2, 3, 4) // child element ids
4424
     * );
4425
     *
4426
     * @param string $table The table of the localized parent record
4427
     * @param int $id The uid of the localized parent record
4428
     * @param array|string $command Defines the command to be performed (see example above)
4429
     */
4430
    protected function inlineLocalizeSynchronize($table, $id, $command)
4431
    {
4432
        $parentRecord = BackendUtility::getRecordWSOL($table, $id);
4433
4434
        // Backward-compatibility handling
4435
        if (!is_array($command)) {
4436
            // <field>, (localize | synchronize | <uid>):
4437
            $parts = GeneralUtility::trimExplode(',', $command);
4438
            $command = [
4439
                'field' => $parts[0],
4440
                // The previous process expected $id to point to the localized record already
4441
                'language' => (int)$parentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']]
4442
            ];
4443
            if (!MathUtility::canBeInterpretedAsInteger($parts[1])) {
4444
                $command['action'] = $parts[1];
4445
            } else {
4446
                $command['ids'] = [$parts[1]];
4447
            }
4448
        }
4449
4450
        // In case the parent record is the default language record, fetch the localization
4451
        if (empty($parentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
4452
            // Fetch the live record
4453
            // @todo: this needs to be revisited, as getRecordLocalization() does a BackendWorkspaceRestriction
4454
            // based on $GLOBALS[BE_USER], which could differ from the $this->BE_USER->workspace value
4455
            $parentRecordLocalization = BackendUtility::getRecordLocalization($table, $id, $command['language'], 'AND t3ver_oid=0');
4456
            if (empty($parentRecordLocalization)) {
4457
                if ($this->enableLogging) {
4458
                    $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']));
4459
                }
4460
                return;
4461
            }
4462
            $parentRecord = $parentRecordLocalization[0];
4463
            $id = $parentRecord['uid'];
4464
            // Process overlay for current selected workspace
4465
            BackendUtility::workspaceOL($table, $parentRecord);
4466
        }
4467
4468
        $field = $command['field'];
4469
        $language = $command['language'];
4470
        $action = $command['action'];
4471
        $ids = $command['ids'];
4472
4473
        if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !isset($GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
4474
            return;
4475
        }
4476
4477
        $config = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
4478
        $foreignTable = $config['foreign_table'];
4479
4480
        $transOrigPointer = (int)$parentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
4481
        $childTransOrigPointerField = $GLOBALS['TCA'][$foreignTable]['ctrl']['transOrigPointerField'];
4482
4483
        if (!$parentRecord || !is_array($parentRecord) || $language <= 0 || !$transOrigPointer) {
4484
            return;
4485
        }
4486
4487
        $inlineSubType = $this->getInlineFieldType($config);
4488
        if ($inlineSubType === false) {
4489
            return;
4490
        }
4491
4492
        $transOrigRecord = BackendUtility::getRecordWSOL($table, $transOrigPointer);
4493
4494
        $removeArray = [];
4495
        $mmTable = $inlineSubType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : '';
4496
        // Fetch children from original language parent:
4497
        /** @var RelationHandler $dbAnalysisOriginal */
4498
        $dbAnalysisOriginal = $this->createRelationHandlerInstance();
4499
        $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config);
4500
        $elementsOriginal = [];
4501
        foreach ($dbAnalysisOriginal->itemArray as $item) {
4502
            $elementsOriginal[$item['id']] = $item;
4503
        }
4504
        unset($dbAnalysisOriginal);
4505
        // Fetch children from current localized parent:
4506
        /** @var RelationHandler $dbAnalysisCurrent */
4507
        $dbAnalysisCurrent = $this->createRelationHandlerInstance();
4508
        $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config);
4509
        // Perform synchronization: Possibly removal of already localized records:
4510
        if ($action === 'synchronize') {
4511
            foreach ($dbAnalysisCurrent->itemArray as $index => $item) {
4512
                $childRecord = BackendUtility::getRecordWSOL($item['table'], $item['id']);
4513
                if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) {
4514
                    $childTransOrigPointer = $childRecord[$childTransOrigPointerField];
4515
                    // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it:
4516
                    if (!isset($elementsOriginal[$childTransOrigPointer])) {
4517
                        unset($dbAnalysisCurrent->itemArray[$index]);
4518
                        $removeArray[$item['table']][$item['id']]['delete'] = 1;
4519
                    }
4520
                }
4521
            }
4522
        }
4523
        // Perform synchronization/localization: Possibly add unlocalized records for original language:
4524
        if ($action === 'localize' || $action === 'synchronize') {
4525
            foreach ($elementsOriginal as $originalId => $item) {
4526
                $item['id'] = $this->localize($item['table'], $item['id'], $language);
4527
                $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4528
                $dbAnalysisCurrent->itemArray[] = $item;
4529
            }
4530
        } elseif (!empty($ids)) {
4531
            foreach ($ids as $childId) {
4532
                if (!MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) {
4533
                    continue;
4534
                }
4535
                $item = $elementsOriginal[$childId];
4536
                $item['id'] = $this->localize($item['table'], $item['id'], $language);
4537
                $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4538
                $dbAnalysisCurrent->itemArray[] = $item;
4539
            }
4540
        }
4541
        // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
4542
        $value = implode(',', $dbAnalysisCurrent->getValueArray());
4543
        $this->registerDBList[$table][$id][$field] = $value;
4544
        // Remove child records (if synchronization requested it):
4545
        if (is_array($removeArray) && !empty($removeArray)) {
4546
            /** @var DataHandler $tce */
4547
            $tce = GeneralUtility::makeInstance(__CLASS__, $this->referenceIndexUpdater);
4548
            $tce->enableLogging = $this->enableLogging;
4549
            $tce->start([], $removeArray, $this->BE_USER);
4550
            $tce->process_cmdmap();
4551
            unset($tce);
4552
        }
4553
        $updateFields = [];
4554
        // Handle, reorder and store relations:
4555
        if ($inlineSubType === 'list') {
4556
            $updateFields = [$field => $value];
4557
        } elseif ($inlineSubType === 'field') {
4558
            $dbAnalysisCurrent->writeForeignField($config, $id);
4559
            $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4560
        } elseif ($inlineSubType === 'mm') {
4561
            $dbAnalysisCurrent->writeMM($config['MM'], $id);
4562
            $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4563
        }
4564
        // Update field referencing to child records of localized parent record:
4565
        if (!empty($updateFields)) {
4566
            $this->updateDB($table, $id, $updateFields);
4567
        }
4568
    }
4569
4570
    /*********************************************
4571
     *
4572
     * Cmd: delete
4573
     *
4574
     ********************************************/
4575
    /**
4576
     * Delete a single record
4577
     *
4578
     * @param string $table Table name
4579
     * @param int $id Record UID
4580
     * @internal should only be used from within DataHandler
4581
     */
4582
    public function deleteAction($table, $id)
4583
    {
4584
        $recordToDelete = BackendUtility::getRecord($table, $id);
4585
4586
        if (is_array($recordToDelete) && isset($recordToDelete['t3ver_wsid']) && (int)$recordToDelete['t3ver_wsid'] !== 0) {
4587
            // When dealing with a workspace record, use discard.
4588
            $this->discard($table, null, $recordToDelete);
4589
            return;
4590
        }
4591
4592
        // Record asked to be deleted was found:
4593
        if (is_array($recordToDelete)) {
4594
            $recordWasDeleted = false;
4595
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
4596
                $hookObj = GeneralUtility::makeInstance($className);
4597
                if (method_exists($hookObj, 'processCmdmap_deleteAction')) {
4598
                    $hookObj->processCmdmap_deleteAction($table, $id, $recordToDelete, $recordWasDeleted, $this);
4599
                }
4600
            }
4601
            // Delete the record if a hook hasn't deleted it yet
4602
            if (!$recordWasDeleted) {
0 ignored issues
show
introduced by
The condition $recordWasDeleted is always false.
Loading history...
4603
                $this->deleteEl($table, $id);
4604
            }
4605
        }
4606
    }
4607
4608
    /**
4609
     * Delete element from any table
4610
     *
4611
     * @param string $table Table name
4612
     * @param int $uid Record UID
4613
     * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record
4614
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4615
     * @param bool $deleteRecordsOnPage If false and if deleting pages, records on the page will not be deleted (edge case while swapping workspaces)
4616
     * @internal should only be used from within DataHandler
4617
     */
4618
    public function deleteEl($table, $uid, $noRecordCheck = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4619
    {
4620
        if ($table === 'pages') {
4621
            $this->deletePages($uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
4622
        } else {
4623
            $this->discardWorkspaceVersionsOfRecord($table, $uid);
4624
            $this->deleteRecord($table, $uid, $noRecordCheck, $forceHardDelete);
4625
        }
4626
    }
4627
4628
    /**
4629
     * Discard workspace overlays of a live record: When a live row
4630
     * is deleted, all existing workspace overlays are discarded.
4631
     *
4632
     * @param string $table Table name
4633
     * @param int $uid Record UID
4634
     * @internal should only be used from within DataHandler
4635
     */
4636
    protected function discardWorkspaceVersionsOfRecord($table, $uid): void
4637
    {
4638
        $versions = BackendUtility::selectVersionsOfRecord($table, $uid, '*', null);
4639
        if ($versions === null) {
4640
            // Null is returned by selectVersionsOfRecord() when table is not workspace aware.
4641
            return;
4642
        }
4643
        foreach ($versions as $record) {
4644
            if ($record['_CURRENT_VERSION'] ?? false) {
4645
                // The live record is included in the result from selectVersionsOfRecord()
4646
                // and marked as '_CURRENT_VERSION'. Skip this one.
4647
                continue;
4648
            }
4649
            // BE user must be put into this workspace temporarily so stuff like refindex updating
4650
            // is properly registered for this workspace when discarding records in there.
4651
            $currentUserWorkspace = $this->BE_USER->workspace;
4652
            $this->BE_USER->workspace = (int)$record['t3ver_wsid'];
4653
            $this->discard($table, null, $record);
4654
            // Switch user back to original workspace
4655
            $this->BE_USER->workspace = $currentUserWorkspace;
4656
        }
4657
    }
4658
4659
    /**
4660
     * Deleting a record
4661
     * This function may not be used to delete pages-records unless the underlying records are already deleted
4662
     * Deletes a record regardless of versioning state (live or offline, doesn't matter, the uid decides)
4663
     * If both $noRecordCheck and $forceHardDelete are set it could even delete a "deleted"-flagged record!
4664
     *
4665
     * @param string $table Table name
4666
     * @param int $uid Record UID
4667
     * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record
4668
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4669
     * @internal should only be used from within DataHandler
4670
     */
4671
    public function deleteRecord($table, $uid, $noRecordCheck = false, $forceHardDelete = false)
4672
    {
4673
        $currentUserWorkspace = (int)$this->BE_USER->workspace;
4674
        $uid = (int)$uid;
4675
        if (!$GLOBALS['TCA'][$table] || !$uid) {
4676
            $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions. [' . $this->BE_USER->errorMsg . ']');
4677
            return;
4678
        }
4679
        // Skip processing already deleted records
4680
        if (!$forceHardDelete && $this->hasDeletedRecord($table, $uid)) {
4681
            return;
4682
        }
4683
4684
        // Checking if there is anything else disallowing deleting the record by checking if editing is allowed
4685
        $fullLanguageAccessCheck = true;
4686
        if ($table === 'pages') {
4687
            // If this is a page translation, the full language access check should not be done
4688
            $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
4689
            if ($defaultLanguagePageId !== $uid) {
4690
                $fullLanguageAccessCheck = false;
4691
            }
4692
        }
4693
        $hasEditAccess = $this->BE_USER->recordEditAccessInternals($table, $uid, false, $forceHardDelete, $fullLanguageAccessCheck);
4694
        if (!$hasEditAccess) {
4695
            $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions');
4696
            return;
4697
        }
4698
        if ($table === 'pages') {
4699
            $perms = Permission::PAGE_DELETE;
4700
        } elseif ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
4701
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
4702
            $perms = Permission::PAGE_EDIT;
4703
        } else {
4704
            $perms = Permission::CONTENT_EDIT;
4705
        }
4706
        if (!$noRecordCheck && !$this->doesRecordExist($table, $uid, $perms)) {
4707
            return;
4708
        }
4709
4710
        $recordToDelete = [];
4711
        $recordWorkspaceId = 0;
4712
        if (BackendUtility::isTableWorkspaceEnabled($table)) {
4713
            $recordToDelete = BackendUtility::getRecord($table, $uid);
4714
            $recordWorkspaceId = (int)$recordToDelete['t3ver_wsid'];
4715
        }
4716
4717
        // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache
4718
        [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
4719
        $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid);
4720
        $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'];
4721
        $databaseErrorMessage = '';
4722
        if ($recordWorkspaceId > 0) {
4723
            // If this is a workspace record, use discard
4724
            $this->BE_USER->workspace = $recordWorkspaceId;
4725
            $this->discard($table, null, $recordToDelete);
4726
            // Switch user back to original workspace
4727
            $this->BE_USER->workspace = $currentUserWorkspace;
4728
        } elseif ($deleteField && !$forceHardDelete) {
4729
            $updateFields = [
4730
                $deleteField => 1
4731
            ];
4732
            if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
4733
                $updateFields[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
4734
            }
4735
            // before deleting this record, check for child records or references
4736
            $this->deleteRecord_procFields($table, $uid);
4737
            try {
4738
                // Delete all l10n records as well
4739
                $this->deletedRecords[$table][] = (int)$uid;
4740
                $this->deleteL10nOverlayRecords($table, $uid);
4741
                GeneralUtility::makeInstance(ConnectionPool::class)
4742
                    ->getConnectionForTable($table)
4743
                    ->update($table, $updateFields, ['uid' => (int)$uid]);
4744
            } catch (DBALException $e) {
4745
                $databaseErrorMessage = $e->getPrevious()->getMessage();
4746
            }
4747
        } else {
4748
            // Delete the hard way...:
4749
            try {
4750
                $this->hardDeleteSingleRecord($table, (int)$uid);
4751
                $this->deletedRecords[$table][] = (int)$uid;
4752
                $this->deleteL10nOverlayRecords($table, $uid);
4753
            } catch (DBALException $e) {
4754
                $databaseErrorMessage = $e->getPrevious()->getMessage();
4755
            }
4756
        }
4757
        if ($this->enableLogging) {
4758
            $state = SystemLogDatabaseAction::DELETE;
4759
            if ($databaseErrorMessage === '') {
4760
                if ($forceHardDelete) {
4761
                    $message = 'Record \'%s\' (%s) was deleted unrecoverable from page \'%s\' (%s)';
4762
                } else {
4763
                    $message = 'Record \'%s\' (%s) was deleted from page \'%s\' (%s)';
4764
                }
4765
                $propArr = $this->getRecordProperties($table, $uid);
4766
                $pagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4767
4768
                $this->log($table, $uid, $state, 0, SystemLogErrorClassification::MESSAGE, $message, 0, [
4769
                    $propArr['header'],
4770
                    $table . ':' . $uid,
4771
                    $pagePropArr['header'],
4772
                    $propArr['pid']
4773
                ], $propArr['event_pid']);
4774
            } else {
4775
                $this->log($table, $uid, $state, 0, SystemLogErrorClassification::TODAYS_SPECIAL, $databaseErrorMessage);
4776
            }
4777
        }
4778
4779
        // Add history entry
4780
        $this->getRecordHistoryStore()->deleteRecord($table, $uid, $this->correlationId);
4781
4782
        // Update reference index with table/uid on left side (recuid)
4783
        $this->updateRefIndex($table, $uid);
4784
        // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
4785
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $currentUserWorkspace);
4786
    }
4787
4788
    /**
4789
     * Used to delete page because it will check for branch below pages and disallowed tables on the page as well.
4790
     *
4791
     * @param int $uid Page id
4792
     * @param bool $force If TRUE, pages are not checked for permission.
4793
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4794
     * @param bool $deleteRecordsOnPage If false, records on the page will not be deleted (edge case while swapping workspaces)
4795
     * @internal should only be used from within DataHandler
4796
     */
4797
    public function deletePages($uid, $force = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4798
    {
4799
        $uid = (int)$uid;
4800
        if ($uid === 0) {
4801
            if ($this->enableLogging) {
4802
                $this->log('pages', $uid, SystemLogGenericAction::UNDEFINED, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'Deleting all pages starting from the root-page is disabled.', -1, [], 0);
4803
            }
4804
            return;
4805
        }
4806
        // Getting list of pages to delete:
4807
        if ($force) {
4808
            // Returns the branch WITHOUT permission checks (0 secures that), so it cannot return -1
4809
            $pageIdsInBranch = $this->doesBranchExist('', $uid, 0, true);
4810
            $res = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
4811
        } else {
4812
            $res = $this->canDeletePage($uid);
4813
        }
4814
        // Perform deletion if not error:
4815
        if (is_array($res)) {
4816
            foreach ($res as $deleteId) {
4817
                $this->deleteSpecificPage($deleteId, $forceHardDelete, $deleteRecordsOnPage);
4818
            }
4819
        } else {
4820
            /** @var FlashMessage $flashMessage */
4821
            $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $res, '', FlashMessage::ERROR, true);
4822
            /** @var FlashMessageService $flashMessageService */
4823
            $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
4824
            $flashMessageService->getMessageQueueByIdentifier()->addMessage($flashMessage);
4825
            $this->newlog($res, SystemLogErrorClassification::USER_ERROR);
4826
        }
4827
    }
4828
4829
    /**
4830
     * Delete a page (or set deleted field to 1) and all records on it.
4831
     *
4832
     * @param int $uid Page id
4833
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4834
     * @param bool $deleteRecordsOnPage If false, records on the page will not be deleted (edge case while swapping workspaces)
4835
     * @internal
4836
     * @see deletePages()
4837
     */
4838
    public function deleteSpecificPage($uid, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4839
    {
4840
        $uid = (int)$uid;
4841
        if (!$uid) {
4842
            // Early void return on invalid uid
4843
            return;
4844
        }
4845
        $forceHardDelete = (bool)$forceHardDelete;
4846
4847
        // Delete either a default language page or a translated page
4848
        $pageIdInDefaultLanguage = $this->getDefaultLanguagePageId($uid);
4849
        $isPageTranslation = false;
4850
        $pageLanguageId = 0;
4851
        if ($pageIdInDefaultLanguage !== $uid) {
4852
            // For translated pages, translated records in other tables (eg. tt_content) for the
4853
            // to-delete translated page have their pid field set to the uid of the default language record,
4854
            // NOT the uid of the translated page record.
4855
            // If a translated page is deleted, only translations of records in other tables of this language
4856
            // should be deleted. The code checks if the to-delete page is a translated page and
4857
            // adapts the query for other tables to use the uid of the default language page as pid together
4858
            // with the language id of the translated page.
4859
            $isPageTranslation = true;
4860
            $pageLanguageId = $this->pageInfo($uid, $GLOBALS['TCA']['pages']['ctrl']['languageField']);
4861
        }
4862
4863
        if ($deleteRecordsOnPage) {
4864
            $tableNames = $this->compileAdminTables();
4865
            foreach ($tableNames as $table) {
4866
                if ($table === 'pages' || ($isPageTranslation && !BackendUtility::isTableLocalizable($table))) {
4867
                    // Skip pages table. And skip table if not translatable, but a translated page is deleted
4868
                    continue;
4869
                }
4870
4871
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
4872
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
4873
                $queryBuilder
4874
                    ->select('uid')
4875
                    ->from($table)
4876
                    // order by uid is needed here to process possible live records first - overlays always
4877
                    // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order,
4878
                    // leading to hard to debug issues. This is especially relevant for the
4879
                    // discardWorkspaceVersionsOfRecord() call below.
4880
                    ->addOrderBy('uid');
4881
4882
                if ($isPageTranslation) {
4883
                    // Only delete records in the specified language
4884
                    $queryBuilder->where(
4885
                        $queryBuilder->expr()->eq(
4886
                            'pid',
4887
                            $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, \PDO::PARAM_INT)
4888
                        ),
4889
                        $queryBuilder->expr()->eq(
4890
                            $GLOBALS['TCA'][$table]['ctrl']['languageField'],
4891
                            $queryBuilder->createNamedParameter($pageLanguageId, \PDO::PARAM_INT)
4892
                        )
4893
                    );
4894
                } else {
4895
                    // Delete all records on this page
4896
                    $queryBuilder->where(
4897
                        $queryBuilder->expr()->eq(
4898
                            'pid',
4899
                            $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
4900
                        )
4901
                    );
4902
                }
4903
4904
                $currentUserWorkspace = (int)$this->BE_USER->workspace;
4905
                if ($currentUserWorkspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
4906
                    // If we are in a workspace, make sure only records of this workspace are deleted.
4907
                    $queryBuilder->andWhere(
4908
                        $queryBuilder->expr()->eq(
4909
                            't3ver_wsid',
4910
                            $queryBuilder->createNamedParameter($currentUserWorkspace, \PDO::PARAM_INT)
4911
                        )
4912
                    );
4913
                }
4914
4915
                $statement = $queryBuilder->execute();
4916
4917
                while ($row = $statement->fetch()) {
4918
                    // Delete any further workspace overlays of the record in question, then delete the record.
4919
                    $this->discardWorkspaceVersionsOfRecord($table, $row['uid']);
4920
                    $this->deleteRecord($table, $row['uid'], true, $forceHardDelete);
4921
                }
4922
            }
4923
        }
4924
4925
        // Delete any further workspace overlays of the record in question, then delete the record.
4926
        $this->discardWorkspaceVersionsOfRecord('pages', $uid);
4927
        $this->deleteRecord('pages', $uid, true, $forceHardDelete);
4928
    }
4929
4930
    /**
4931
     * Used to evaluate if a page can be deleted
4932
     *
4933
     * @param int $uid Page id
4934
     * @return int[]|string If array: List of page uids to traverse and delete (means OK), if string: error message.
4935
     * @internal should only be used from within DataHandler
4936
     */
4937
    public function canDeletePage($uid)
4938
    {
4939
        $uid = (int)$uid;
4940
        $isTranslatedPage = null;
4941
4942
        // If we may at all delete this page
4943
        // If this is a page translation, do the check against the perms_* of the default page
4944
        // Because it is currently only deleting the translation
4945
        $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
4946
        if ($defaultLanguagePageId !== $uid) {
4947
            if ($this->doesRecordExist('pages', (int)$defaultLanguagePageId, Permission::PAGE_DELETE)) {
4948
                $isTranslatedPage = true;
4949
            } else {
4950
                return 'Attempt to delete page without permissions';
4951
            }
4952
        } elseif (!$this->doesRecordExist('pages', $uid, Permission::PAGE_DELETE)) {
4953
            return 'Attempt to delete page without permissions';
4954
        }
4955
4956
        $pageIdsInBranch = $this->doesBranchExist('', $uid, Permission::PAGE_DELETE, true);
4957
4958
        if ($pageIdsInBranch === -1) {
4959
            return 'Attempt to delete pages in branch without permissions';
4960
        }
4961
4962
        $pagesInBranch = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
4963
4964
        if ($disallowedTables = $this->checkForRecordsFromDisallowedTables($pagesInBranch)) {
4965
            return 'Attempt to delete records from disallowed tables (' . implode(', ', $disallowedTables) . ')';
4966
        }
4967
4968
        foreach ($pagesInBranch as $pageInBranch) {
4969
            if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, $isTranslatedPage ? false : true)) {
4970
                return 'Attempt to delete page which has prohibited localizations.';
4971
            }
4972
        }
4973
        return $pagesInBranch;
4974
    }
4975
4976
    /**
4977
     * Returns TRUE if record CANNOT be deleted, otherwise FALSE. Used to check before the versioning API allows a record to be marked for deletion.
4978
     *
4979
     * @param string $table Record Table
4980
     * @param int $id Record UID
4981
     * @return string Returns a string IF there is an error (error string explaining). FALSE means record can be deleted
4982
     * @internal should only be used from within DataHandler
4983
     */
4984
    public function cannotDeleteRecord($table, $id)
4985
    {
4986
        if ($table === 'pages') {
4987
            $res = $this->canDeletePage($id);
4988
            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...
4989
        }
4990
        if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
4991
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
4992
            $perms = Permission::PAGE_EDIT;
4993
        } else {
4994
            $perms = Permission::CONTENT_EDIT;
4995
        }
4996
        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...
4997
    }
4998
4999
    /**
5000
     * Before a record is deleted, check if it has references such as inline type or MM references.
5001
     * If so, set these child records also to be deleted.
5002
     *
5003
     * @param string $table Record Table
5004
     * @param int $uid Record UID
5005
     * @see deleteRecord()
5006
     * @internal should only be used from within DataHandler
5007
     */
5008
    public function deleteRecord_procFields($table, $uid)
5009
    {
5010
        $conf = $GLOBALS['TCA'][$table]['columns'];
5011
        $row = BackendUtility::getRecord($table, $uid, '*', '', false);
5012
        if (empty($row)) {
5013
            return;
5014
        }
5015
        foreach ($row as $field => $value) {
5016
            $this->deleteRecord_procBasedOnFieldType($table, $uid, $field, $value, $conf[$field]['config'] ?? []);
5017
        }
5018
    }
5019
5020
    /**
5021
     * Process fields of a record to be deleted and search for special handling, like
5022
     * inline type, MM records, etc.
5023
     *
5024
     * @param string $table Record Table
5025
     * @param int $uid Record UID
5026
     * @param string $field Record field
5027
     * @param string $value Record field value
5028
     * @param array $conf TCA configuration of current field
5029
     * @see deleteRecord()
5030
     * @internal should only be used from within DataHandler
5031
     */
5032
    public function deleteRecord_procBasedOnFieldType($table, $uid, $field, $value, $conf): void
5033
    {
5034
        if (!isset($conf['type'])) {
5035
            return;
5036
        }
5037
        if ($conf['type'] === 'inline') {
5038
            $foreign_table = $conf['foreign_table'];
5039
            if ($foreign_table) {
5040
                $inlineType = $this->getInlineFieldType($conf);
5041
                if ($inlineType === 'list' || $inlineType === 'field') {
5042
                    /** @var RelationHandler $dbAnalysis */
5043
                    $dbAnalysis = $this->createRelationHandlerInstance();
5044
                    $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
5045
                    $dbAnalysis->undeleteRecord = true;
5046
5047
                    $enableCascadingDelete = true;
5048
                    // non type save comparison is intended!
5049
                    if (isset($conf['behaviour']['enableCascadingDelete']) && $conf['behaviour']['enableCascadingDelete'] == false) {
5050
                        $enableCascadingDelete = false;
5051
                    }
5052
5053
                    // Walk through the items and remove them
5054
                    foreach ($dbAnalysis->itemArray as $v) {
5055
                        if ($enableCascadingDelete) {
5056
                            $this->deleteAction($v['table'], $v['id']);
5057
                        }
5058
                    }
5059
                }
5060
            }
5061
        } elseif ($this->isReferenceField($conf)) {
5062
            $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5063
            $dbAnalysis = $this->createRelationHandlerInstance();
5064
            $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $uid, $table, $conf);
5065
            foreach ($dbAnalysis->itemArray as $v) {
5066
                $this->updateRefIndex($v['table'], $v['id']);
5067
            }
5068
        }
5069
    }
5070
5071
    /**
5072
     * Find l10n-overlay records and perform the requested delete action for these records.
5073
     *
5074
     * @param string $table Record Table
5075
     * @param int $uid Record UID
5076
     * @internal should only be used from within DataHandler
5077
     */
5078
    public function deleteL10nOverlayRecords($table, $uid)
5079
    {
5080
        // Check whether table can be localized
5081
        if (!BackendUtility::isTableLocalizable($table)) {
5082
            return;
5083
        }
5084
5085
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5086
        $queryBuilder->getRestrictions()
5087
            ->removeAll()
5088
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
5089
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
5090
5091
        $queryBuilder->select('*')
5092
            ->from($table)
5093
            ->where(
5094
                $queryBuilder->expr()->eq(
5095
                    $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5096
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5097
                )
5098
            );
5099
5100
        $result = $queryBuilder->execute();
5101
        while ($record = $result->fetch()) {
5102
            // Ignore workspace delete placeholders. Those records have been marked for
5103
            // deletion before - deleting them again in a workspace would revert that state.
5104
            if ((int)$this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5105
                BackendUtility::workspaceOL($table, $record, $this->BE_USER->workspace);
5106
                if (VersionState::cast($record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)) {
5107
                    continue;
5108
                }
5109
            }
5110
            $this->deleteAction($table, (int)$record['t3ver_oid'] > 0 ? (int)$record['t3ver_oid'] : (int)$record['uid']);
5111
        }
5112
    }
5113
5114
    /*********************************************
5115
     *
5116
     * Cmd: undelete / restore
5117
     *
5118
     ********************************************/
5119
5120
    /**
5121
     * Restore live records by setting soft-delete flag to 0.
5122
     *
5123
     * Usually only used by ext:recycler.
5124
     * Connected relations (eg. inline) are restored, too.
5125
     * Additional existing localizations are not restored.
5126
     *
5127
     * @param string $table Record table name
5128
     * @param int $uid Record uid
5129
     */
5130
    protected function undeleteRecord(string $table, int $uid): void
5131
    {
5132
        $record = BackendUtility::getRecord($table, $uid, '*', '', false);
5133
        $deleteField = (string)($GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '');
5134
        $timestampField = (string)($GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? '');
5135
5136
        if ($record === null
5137
            || $deleteField === ''
5138
            || !isset($record[$deleteField])
5139
            || (bool)$record[$deleteField] === false
5140
            || ($timestampField !== '' && !isset($record[$timestampField]))
5141
            || (int)$this->BE_USER->workspace > 0
5142
            || (BackendUtility::isTableWorkspaceEnabled($table) && (int)($record['t3ver_wsid'] ?? 0) > 0)
5143
        ) {
5144
            // Return early and silently, if:
5145
            // * Record not found
5146
            // * Table is not soft-delete aware
5147
            // * Record does not have deleted field - db analyzer not up-to-date?
5148
            // * Record is not deleted - may eventually happen via recursion with self referencing records?
5149
            // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date?
5150
            // * User is in a workspace - does not make sense
5151
            // * Record is in a workspace - workspace records are not soft-delete aware
5152
            return;
5153
        }
5154
5155
        $recordPid = (int)($record['pid'] ?? 0);
5156
        if ($recordPid > 0) {
5157
            // Record is not on root level. Parent page record must exist and must not be deleted itself.
5158
            $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5159
            if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
5160
                $this->log(
5161
                    $table,
5162
                    $uid,
5163
                    SystemLogDatabaseAction::DELETE,
5164
                    0,
5165
                    SystemLogErrorClassification::USER_ERROR,
5166
                    sprintf('Record "%s:%s" can\'t be restored: The page:%s containing it does not exist or is soft-deleted.', $table, $uid, $recordPid),
5167
                    0,
5168
                    [],
5169
                    $recordPid
5170
                );
5171
                return;
5172
            }
5173
        }
5174
5175
        // @todo: When restoring a not-default language record, it should be verified the default language
5176
        // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted?
5177
5178
        if (!$this->BE_USER->recordEditAccessInternals($table, $record, false, true)) {
5179
            // User misses access permissions to record
5180
            $this->log(
5181
                $table,
5182
                $uid,
5183
                SystemLogDatabaseAction::DELETE,
5184
                0,
5185
                SystemLogErrorClassification::USER_ERROR,
5186
                sprintf('Record "%s:%s" can\'t be restored: Insufficient user permissions.', $table, $uid),
5187
                0,
5188
                [],
5189
                $recordPid
5190
            );
5191
            return;
5192
        }
5193
5194
        // Restore referenced child records
5195
        $this->undeleteRecordRelations($table, $uid, $record);
5196
5197
        // Restore record
5198
        $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...
5199
        if ($timestampField !== '') {
5200
            $updateFields[$timestampField] = $GLOBALS['EXEC_TIME'];
5201
        }
5202
        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table)
5203
            ->update(
5204
                $table,
5205
                $updateFields,
5206
                ['uid' => $uid]
5207
            );
5208
5209
        if ($this->enableLogging) {
5210
            $this->log(
5211
                $table,
5212
                $uid,
5213
                SystemLogDatabaseAction::INSERT,
5214
                0,
5215
                SystemLogErrorClassification::MESSAGE,
5216
                sprintf('Record "%s:%s" was restored on page:%s', $table, $uid, $recordPid),
5217
                0,
5218
                [],
5219
                $recordPid
5220
            );
5221
        }
5222
5223
        // Register cache clearing of page, or parent page if a page is restored.
5224
        $this->registerRecordIdForPageCacheClearing($table, $uid, $recordPid);
5225
        // Add history entry
5226
        $this->getRecordHistoryStore()->undeleteRecord($table, $uid, $this->correlationId);
5227
        // Update reference index with table/uid on left side (recuid)
5228
        $this->updateRefIndex($table, $uid);
5229
        // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored.
5230
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, 0);
5231
    }
5232
5233
    /**
5234
     * Check if a to-restore record has inline references and restore them.
5235
     *
5236
     * @param string $table Record table name
5237
     * @param int $uid Record uid
5238
     * @param array $record Record row
5239
     * @todo: Add functional test undelete coverage to verify details, some details seem to be missing.
5240
     */
5241
    protected function undeleteRecordRelations(string $table, int $uid, array $record): void
5242
    {
5243
        foreach ($record as $fieldName => $value) {
5244
            $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'] ?? [];
5245
            $fieldType = (string)($fieldConfig['type'] ?? '');
5246
            if (empty($fieldConfig) || !is_array($fieldConfig) || $fieldType === '') {
5247
                continue;
5248
            }
5249
            $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5250
            if ($fieldType === 'inline') {
5251
                // @todo: Inline MM not handled here, and what about group / select?
5252
                if ($foreignTable === ''
5253
                    || !in_array($this->getInlineFieldType($fieldConfig), ['list', 'field'], true)
5254
                ) {
5255
                    continue;
5256
                }
5257
                $relationHandler = $this->createRelationHandlerInstance();
5258
                $relationHandler->start($value, $foreignTable, '', $uid, $table, $fieldConfig);
5259
                $relationHandler->undeleteRecord = true;
5260
                foreach ($relationHandler->itemArray as $reference) {
5261
                    $this->undeleteRecord($reference['table'], (int)$reference['id']);
5262
                }
5263
            } elseif ($this->isReferenceField($fieldConfig)) {
5264
                $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable;
5265
                $relationHandler = $this->createRelationHandlerInstance();
5266
                $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', $uid, $table, $fieldConfig);
5267
                foreach ($relationHandler->itemArray as $reference) {
5268
                    // @todo: Unsure if this is ok / enough. Needs coverage.
5269
                    $this->updateRefIndex($reference['table'], $reference['id']);
5270
                }
5271
            }
5272
        }
5273
    }
5274
5275
    /*********************************************
5276
     *
5277
     * Cmd: Workspace discard & flush
5278
     *
5279
     ********************************************/
5280
5281
    /**
5282
     * Discard a versioned record from this workspace. This deletes records from the database - no soft delete.
5283
     * This main entry method is called recursive for sub pages, localizations, relations and records on a page.
5284
     * The method checks user access and gathers facts about this record to hand the deletion over to detail methods.
5285
     *
5286
     * The incoming $uid or $row can be anything: The workspace of current user is respected and only records
5287
     * of current user workspace are discarded. If giving a live record uid, the versioned overly will be fetched.
5288
     *
5289
     * @param string $table Database table name
5290
     * @param int|null $uid Uid of live or versioned record to be discarded, or null if $record is given
5291
     * @param array|null $record Record row that should be discarded. Used instead of $uid within recursion.
5292
     * @internal should only be used from within DataHandler
5293
     */
5294
    public function discard(string $table, ?int $uid, array $record = null): void
5295
    {
5296
        if ($uid === null && $record === null) {
5297
            throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491);
5298
        }
5299
5300
        // Fetch record we are dealing with if not given
5301
        if ($record === null) {
5302
            $record = BackendUtility::getRecord($table, (int)$uid);
5303
        }
5304
        if (!is_array($record)) {
5305
            return;
5306
        }
5307
        $uid = (int)$record['uid'];
5308
5309
        // Call hook and return if hook took care of the element
5310
        $recordWasDiscarded = false;
5311
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5312
            $hookObj = GeneralUtility::makeInstance($className);
5313
            if (method_exists($hookObj, 'processCmdmap_discardAction')) {
5314
                $hookObj->processCmdmap_discardAction($table, $uid, $record, $recordWasDiscarded);
5315
            }
5316
        }
5317
5318
        $userWorkspace = (int)$this->BE_USER->workspace;
5319
        if ($recordWasDiscarded
5320
            || $userWorkspace === 0
5321
            || !BackendUtility::isTableWorkspaceEnabled($table)
5322
            || $this->hasDeletedRecord($table, $uid)
5323
        ) {
5324
            return;
5325
        }
5326
5327
        // Gather versioned record
5328
        $versionRecord = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $versionRecord is dead and can be removed.
Loading history...
5329
        if ((int)$record['t3ver_wsid'] === 0) {
5330
            $record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, $uid);
5331
        }
5332
        if (!is_array($record)) {
5333
            return;
5334
        }
5335
        $versionRecord = $record;
5336
5337
        // User access checks
5338
        if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) {
5339
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: Different workspace', SystemLogErrorClassification::USER_ERROR);
5340
            return;
5341
        }
5342
        if ($errorCode = $this->BE_USER->workspaceCannotEditOfflineVersion($table, $versionRecord['uid'])) {
5343
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: ' . $errorCode, SystemLogErrorClassification::USER_ERROR);
5344
            return;
5345
        }
5346
        if (!$this->checkRecordUpdateAccess($table, $versionRecord['uid'])) {
5347
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: User has no edit access', SystemLogErrorClassification::USER_ERROR);
5348
            return;
5349
        }
5350
        $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] !== 0);
5351
        if (!$this->BE_USER->recordEditAccessInternals($table, $versionRecord, false, true, $fullLanguageAccessCheck)) {
5352
            $this->newlog('Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: User has no delete access', SystemLogErrorClassification::USER_ERROR);
5353
            return;
5354
        }
5355
5356
        // Perform discard operations
5357
        $versionState = VersionState::cast($versionRecord['t3ver_state']);
5358
        if ($table === 'pages' && $versionState->equals(VersionState::NEW_PLACEHOLDER)) {
5359
            // When discarding a new page, there can be new sub pages and new records.
5360
            // Those need to be discarded, otherwise they'd end up as records without parent page.
5361
            $this->discardSubPagesAndRecordsOnPage($versionRecord);
5362
        }
5363
5364
        $this->discardLocalizationOverlayRecords($table, $versionRecord);
5365
        $this->discardRecordRelations($table, $versionRecord);
5366
        $this->hardDeleteSingleRecord($table, (int)$versionRecord['uid']);
5367
        $this->deletedRecords[$table][] = (int)$versionRecord['uid'];
5368
        $this->registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace);
5369
        $this->getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId);
5370
        $this->log(
5371
            $table,
5372
            (int)$versionRecord['uid'],
5373
            SystemLogDatabaseAction::DELETE,
5374
            0,
5375
            SystemLogErrorClassification::MESSAGE,
5376
            'Record ' . $table . ':' . $versionRecord['uid'] . ' was deleted unrecoverable from page ' . $versionRecord['pid'],
5377
            0,
5378
            [],
5379
            (int)$versionRecord['pid']
5380
        );
5381
    }
5382
5383
    /**
5384
     * Also discard any sub pages and records of a new parent page if this page is discarded.
5385
     * Discarding only in specific localization, if needed.
5386
     *
5387
     * @param array $page Page record row
5388
     */
5389
    protected function discardSubPagesAndRecordsOnPage(array $page): void
5390
    {
5391
        $isLocalizedPage = false;
5392
        $sysLanguageId = (int)$page[$GLOBALS['TCA']['pages']['ctrl']['languageField']];
5393
        $versionState = VersionState::cast($page['t3ver_state']);
5394
        if ($sysLanguageId > 0) {
5395
            // New or moved localized page.
5396
            // Discard records on this page localization, but no sub pages.
5397
            // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent.
5398
            // @todo: Discard other page translations that inherit from this?! (l10n_source field)
5399
            $isLocalizedPage = true;
5400
            $pid = (int)$page[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
5401
        } elseif ($versionState->equals(VersionState::NEW_PLACEHOLDER)) {
5402
            // New default language page.
5403
            // Discard any sub pages and all other records of this page, including any page localizations.
5404
            // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid
5405
            // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here.
5406
            $pid = (int)$page['uid'];
5407
        } else {
5408
            // Moved default language page.
5409
            // Discard any sub pages and all other records of this page, including any page localizations.
5410
            $pid = (int)$page['t3ver_oid'];
5411
        }
5412
        $tables = $this->compileAdminTables();
5413
        foreach ($tables as $table) {
5414
            if (($isLocalizedPage && $table === 'pages')
5415
                || ($isLocalizedPage && !BackendUtility::isTableLocalizable($table))
5416
                || !BackendUtility::isTableWorkspaceEnabled($table)
5417
            ) {
5418
                continue;
5419
            }
5420
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5421
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5422
            $queryBuilder->select('*')
5423
                ->from($table)
5424
                ->where(
5425
                    $queryBuilder->expr()->eq(
5426
                        'pid',
5427
                        $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)
5428
                    ),
5429
                    $queryBuilder->expr()->eq(
5430
                        't3ver_wsid',
5431
                        $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, \PDO::PARAM_INT)
5432
                    )
5433
                );
5434
            if ($isLocalizedPage) {
5435
                // Add sys_language_uid = x restriction if discarding a localized page
5436
                $queryBuilder->andWhere(
5437
                    $queryBuilder->expr()->eq(
5438
                        $GLOBALS['TCA'][$table]['ctrl']['languageField'],
5439
                        $queryBuilder->createNamedParameter($sysLanguageId, \PDO::PARAM_INT)
5440
                    )
5441
                );
5442
            }
5443
            $statement = $queryBuilder->execute();
5444
            while ($row = $statement->fetch()) {
5445
                $this->discard($table, null, $row);
5446
            }
5447
        }
5448
    }
5449
5450
    /**
5451
     * Discard record relations like inline and MM of a record.
5452
     *
5453
     * @param string $table Table name of this record
5454
     * @param array $record The record row to handle
5455
     */
5456
    protected function discardRecordRelations(string $table, array $record): void
5457
    {
5458
        foreach ($record as $field => $value) {
5459
            $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null;
5460
            if (!isset($fieldConfig['type'])) {
5461
                continue;
5462
            }
5463
            if ($fieldConfig['type'] === 'inline') {
5464
                $foreignTable = $fieldConfig['foreign_table'] ?? null;
5465
                if (!$foreignTable
5466
                     || (isset($fieldConfig['behaviour']['enableCascadingDelete'])
5467
                        && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false)
5468
                ) {
5469
                    continue;
5470
                }
5471
                $inlineType = $this->getInlineFieldType($fieldConfig);
5472
                if ($inlineType === 'list' || $inlineType === 'field') {
5473
                    $dbAnalysis = $this->createRelationHandlerInstance();
5474
                    $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)$record['uid'], $table, $fieldConfig);
5475
                    $dbAnalysis->undeleteRecord = true;
5476
                    foreach ($dbAnalysis->itemArray as $relationRecord) {
5477
                        $this->discard($relationRecord['table'], (int)$relationRecord['id']);
5478
                    }
5479
                }
5480
            } elseif ($this->isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) {
5481
                $this->discardMmRelations($fieldConfig, $record);
5482
            }
5483
            // @todo not inline and not mm - probably not handled correctly and has no proper test coverage yet
5484
        }
5485
    }
5486
5487
    /**
5488
     * When a workspace record row is discarded that has mm relations, existing mm table rows need
5489
     * to be deleted. The method performs the delete operation depending on TCA field configuration.
5490
     *
5491
     * @param array $fieldConfig TCA configuration of this field
5492
     * @param array $record The full record of a left- or ride-side relation
5493
     */
5494
    protected function discardMmRelations(array $fieldConfig, array $record): void
5495
    {
5496
        $recordUid = (int)$record['uid'];
5497
        $mmTableName = $fieldConfig['MM'];
5498
        // left - non foreign - uid_local vs. right - foreign - uid_foreign decision
5499
        $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local';
5500
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
5501
        $queryBuilder->delete($mmTableName)->where(
5502
            // uid_local = given uid OR uid_foreign = given uid
5503
            $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, \PDO::PARAM_INT))
5504
        );
5505
        if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) {
5506
            $queryBuilder->andWhere(
5507
                QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, $fieldConfig['MM_table_where']))
5508
            );
5509
        }
5510
        $mmMatchFields = $fieldConfig['MM_match_fields'] ?? [];
5511
        foreach ($mmMatchFields as $fieldName => $fieldValue) {
5512
            $queryBuilder->andWhere(
5513
                $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue, \PDO::PARAM_STR))
5514
            );
5515
        }
5516
        $queryBuilder->execute();
5517
    }
5518
5519
    /**
5520
     * Find localization overlays of a record and discard them.
5521
     *
5522
     * @param string $table Table of this record
5523
     * @param array $record Record row
5524
     */
5525
    protected function discardLocalizationOverlayRecords(string $table, array $record): void
5526
    {
5527
        if (!BackendUtility::isTableLocalizable($table)) {
5528
            return;
5529
        }
5530
        $uid = (int)$record['uid'];
5531
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5532
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5533
        $statement = $queryBuilder->select('*')
5534
            ->from($table)
5535
            ->where(
5536
                $queryBuilder->expr()->eq(
5537
                    $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5538
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5539
                ),
5540
                $queryBuilder->expr()->eq(
5541
                    't3ver_wsid',
5542
                    $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, \PDO::PARAM_INT)
5543
                )
5544
            )
5545
            ->execute();
5546
        while ($record = $statement->fetch()) {
5547
            $this->discard($table, null, $record);
5548
        }
5549
    }
5550
5551
    /*********************************************
5552
     *
5553
     * Cmd: Versioning
5554
     *
5555
     ********************************************/
5556
    /**
5557
     * Creates a new version of a record
5558
     * (Requires support in the table)
5559
     *
5560
     * @param string $table Table name
5561
     * @param int $id Record uid to versionize
5562
     * @param string $label Version label
5563
     * @param bool $delete If TRUE, the version is created to delete the record.
5564
     * @return int|null Returns the id of the new version (if any)
5565
     * @see copyRecord()
5566
     * @internal should only be used from within DataHandler
5567
     */
5568
    public function versionizeRecord($table, $id, $label, $delete = false)
5569
    {
5570
        $id = (int)$id;
5571
        // Stop any actions if the record is marked to be deleted:
5572
        // (this can occur if IRRE elements are versionized and child elements are removed)
5573
        if ($this->isElementToBeDeleted($table, $id)) {
5574
            return null;
5575
        }
5576
        if (!BackendUtility::isTableWorkspaceEnabled($table) || $id <= 0) {
5577
            $this->newlog('Versioning is not supported for this table "' . $table . '" / ' . $id, SystemLogErrorClassification::USER_ERROR);
5578
            return null;
5579
        }
5580
5581
        // Fetch record with permission check
5582
        $row = $this->recordInfoWithPermissionCheck($table, $id, Permission::PAGE_SHOW);
5583
5584
        // This checks if the record can be selected which is all that a copy action requires.
5585
        if ($row === false) {
5586
            $this->newlog(
5587
                'The record does not exist or you don\'t have correct permissions to make a new version (copy) of this record "' . $table . ':' . $id . '"',
5588
                SystemLogErrorClassification::USER_ERROR
5589
            );
5590
            return null;
5591
        }
5592
5593
        // Record must be online record, otherwise we would create a version of a version
5594
        if (($row['t3ver_oid'] ?? 0) > 0) {
5595
            $this->newlog('Record "' . $table . ':' . $id . '" you wanted to versionize was already a version in archive (record has an online ID)!', SystemLogErrorClassification::USER_ERROR);
5596
            return null;
5597
        }
5598
5599
        if ($delete && $this->cannotDeleteRecord($table, $id)) {
5600
            $this->newlog('Record cannot be deleted: ' . $this->cannotDeleteRecord($table, $id), SystemLogErrorClassification::USER_ERROR);
5601
            return null;
5602
        }
5603
5604
        // Set up the values to override when making a raw-copy:
5605
        $overrideArray = [
5606
            't3ver_oid' => $id,
5607
            't3ver_wsid' => $this->BE_USER->workspace,
5608
            't3ver_state' => (string)($delete ? new VersionState(VersionState::DELETE_PLACEHOLDER) : new VersionState(VersionState::DEFAULT_STATE)),
5609
            't3ver_stage' => 0,
5610
        ];
5611
        if ($GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
5612
            $overrideArray[$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
5613
        }
5614
        // Checking if the record already has a version in the current workspace of the backend user
5615
        $versionRecord = ['uid' => null];
5616
        if ($this->BE_USER->workspace !== 0) {
5617
            // Look for version already in workspace:
5618
            $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid');
5619
        }
5620
        // Create new version of the record and return the new uid
5621
        if (empty($versionRecord['uid'])) {
5622
            // Create raw-copy and return result:
5623
            // The information of the label to be used for the workspace record
5624
            // as well as the information whether the record shall be removed
5625
            // must be forwarded (creating delete placeholders on a workspace are
5626
            // done by copying the record and override several fields).
5627
            $workspaceOptions = [
5628
                'delete' => $delete,
5629
                'label' => $label,
5630
            ];
5631
            return $this->copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions);
5632
        }
5633
        // Reuse the existing record and return its uid
5634
        // (prior to TYPO3 CMS 6.2, an error was thrown here, which
5635
        // did not make much sense since the information is available)
5636
        return $versionRecord['uid'];
5637
    }
5638
5639
    /**
5640
     * Swaps MM-relations for current/swap record, see version_swap()
5641
     *
5642
     * @param string $table Table for the two input records
5643
     * @param int $id Current record (about to go offline)
5644
     * @param int $swapWith Swap record (about to go online)
5645
     * @see version_swap()
5646
     * @internal should only be used from within DataHandler
5647
     */
5648
    public function version_remapMMForVersionSwap($table, $id, $swapWith)
5649
    {
5650
        // Actually, selecting the records fully is only need if flexforms are found inside... This could be optimized ...
5651
        $currentRec = BackendUtility::getRecord($table, $id);
5652
        $swapRec = BackendUtility::getRecord($table, $swapWith);
5653
        $this->version_remapMMForVersionSwap_reg = [];
5654
        $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
5655
        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $fConf) {
5656
            $conf = $fConf['config'];
5657
            if ($this->isReferenceField($conf)) {
5658
                $allowedTables = $conf['type'] === 'group' ? ($conf['allowed'] ?? '') : $conf['foreign_table'];
5659
                $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
5660
                if ($conf['MM'] ?? false) {
5661
                    $dbAnalysis = $this->createRelationHandlerInstance();
5662
                    $dbAnalysis->start('', $allowedTables, $conf['MM'], $id, $table, $conf);
5663
                    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

5663
                    if (!empty($dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prependName))) {
Loading history...
5664
                        $this->version_remapMMForVersionSwap_reg[$id][$field] = [$dbAnalysis, $conf['MM'], $prependName];
5665
                    }
5666
                    $dbAnalysis = $this->createRelationHandlerInstance();
5667
                    $dbAnalysis->start('', $allowedTables, $conf['MM'], $swapWith, $table, $conf);
5668
                    if (!empty($dbAnalysis->getValueArray($prependName))) {
5669
                        $this->version_remapMMForVersionSwap_reg[$swapWith][$field] = [$dbAnalysis, $conf['MM'], $prependName];
5670
                    }
5671
                }
5672
            } elseif ($conf['type'] === 'flex') {
5673
                // Current record
5674
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5675
                    $fConf,
5676
                    $table,
5677
                    $field,
5678
                    $currentRec
5679
                );
5680
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5681
                $currentValueArray = GeneralUtility::xml2array($currentRec[$field]);
5682
                if (is_array($currentValueArray)) {
5683
                    $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $id, $field], 'version_remapMMForVersionSwap_flexFormCallBack');
5684
                }
5685
                // Swap record
5686
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5687
                    $fConf,
5688
                    $table,
5689
                    $field,
5690
                    $swapRec
5691
                );
5692
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5693
                $currentValueArray = GeneralUtility::xml2array($swapRec[$field]);
5694
                if (is_array($currentValueArray)) {
5695
                    $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $swapWith, $field], 'version_remapMMForVersionSwap_flexFormCallBack');
5696
                }
5697
            }
5698
        }
5699
        // Execute:
5700
        $this->version_remapMMForVersionSwap_execSwap($table, $id, $swapWith);
5701
    }
5702
5703
    /**
5704
     * Callback function for traversing the FlexForm structure in relation to ...
5705
     *
5706
     * @param array $pParams Array of parameters in num-indexes: table, uid, field
5707
     * @param array $dsConf TCA field configuration (from Data Structure XML)
5708
     * @param string $dataValue The value of the flexForm field
5709
     * @param string $dataValue_ext1 Not used.
5710
     * @param string $dataValue_ext2 Not used.
5711
     * @param string $path Path in flexforms
5712
     * @see version_remapMMForVersionSwap()
5713
     * @see checkValue_flex_procInData_travDS()
5714
     * @internal should only be used from within DataHandler
5715
     */
5716
    public function version_remapMMForVersionSwap_flexFormCallBack($pParams, $dsConf, $dataValue, $dataValue_ext1, $dataValue_ext2, $path)
5717
    {
5718
        // Extract parameters:
5719
        [$table, $uid, $field] = $pParams;
5720
        if ($this->isReferenceField($dsConf)) {
5721
            $allowedTables = $dsConf['type'] === 'group' ? $dsConf['allowed'] : $dsConf['foreign_table'];
5722
            $prependName = $dsConf['type'] === 'group' ? $dsConf['prepend_tname'] : '';
5723
            if ($dsConf['MM']) {
5724
                /** @var RelationHandler $dbAnalysis */
5725
                $dbAnalysis = $this->createRelationHandlerInstance();
5726
                $dbAnalysis->start('', $allowedTables, $dsConf['MM'], $uid, $table, $dsConf);
5727
                $this->version_remapMMForVersionSwap_reg[$uid][$field . '/' . $path] = [$dbAnalysis, $dsConf['MM'], $prependName];
5728
            }
5729
        }
5730
    }
5731
5732
    /**
5733
     * Performing the remapping operations found necessary in version_remapMMForVersionSwap()
5734
     * 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.
5735
     *
5736
     * @param string $table Table for the two input records
5737
     * @param int $id Current record (about to go offline)
5738
     * @param int $swapWith Swap record (about to go online)
5739
     * @see version_remapMMForVersionSwap()
5740
     * @internal should only be used from within DataHandler
5741
     */
5742
    public function version_remapMMForVersionSwap_execSwap($table, $id, $swapWith)
5743
    {
5744
        if (is_array($this->version_remapMMForVersionSwap_reg[$id])) {
5745
            foreach ($this->version_remapMMForVersionSwap_reg[$id] as $field => $str) {
5746
                $str[0]->remapMM($str[1], $id, -$id, $str[2]);
5747
            }
5748
        }
5749
        if (is_array($this->version_remapMMForVersionSwap_reg[$swapWith])) {
5750
            foreach ($this->version_remapMMForVersionSwap_reg[$swapWith] as $field => $str) {
5751
                $str[0]->remapMM($str[1], $swapWith, $id, $str[2]);
5752
            }
5753
        }
5754
        if (is_array($this->version_remapMMForVersionSwap_reg[$id])) {
5755
            foreach ($this->version_remapMMForVersionSwap_reg[$id] as $field => $str) {
5756
                $str[0]->remapMM($str[1], -$id, $swapWith, $str[2]);
5757
            }
5758
        }
5759
    }
5760
5761
    /*********************************************
5762
     *
5763
     * Cmd: Helper functions
5764
     *
5765
     ********************************************/
5766
5767
    /**
5768
     * Returns an instance of DataHandler for handling local datamaps/cmdmaps
5769
     *
5770
     * @return DataHandler
5771
     */
5772
    protected function getLocalTCE()
5773
    {
5774
        $copyTCE = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
5775
        $copyTCE->copyTree = $this->copyTree;
5776
        $copyTCE->enableLogging = $this->enableLogging;
5777
        // Transformations should NOT be carried out during copy
5778
        $copyTCE->dontProcessTransformations = true;
5779
        // make sure the isImporting flag is transferred, so all hooks know if
5780
        // the current process is an import process
5781
        $copyTCE->isImporting = $this->isImporting;
5782
        $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords;
5783
        $copyTCE->bypassWorkspaceRestrictions = $this->bypassWorkspaceRestrictions;
5784
        return $copyTCE;
5785
    }
5786
5787
    /**
5788
     * Processes the fields with references as registered during the copy process. This includes all FlexForm fields which had references.
5789
     * @internal should only be used from within DataHandler
5790
     */
5791
    public function remapListedDBRecords()
5792
    {
5793
        if (!empty($this->registerDBList)) {
5794
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
5795
            foreach ($this->registerDBList as $table => $records) {
5796
                foreach ($records as $uid => $fields) {
5797
                    $newData = [];
5798
                    $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid];
5799
                    $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate);
5800
                    foreach ($fields as $fieldName => $value) {
5801
                        $conf = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'];
5802
                        switch ($conf['type']) {
5803
                            case 'group':
5804
                            case 'select':
5805
                                $vArray = $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
5806
                                if (is_array($vArray)) {
5807
                                    $newData[$fieldName] = implode(',', $vArray);
5808
                                }
5809
                                break;
5810
                            case 'flex':
5811
                                if ($value === 'FlexForm_reference') {
5812
                                    // This will fetch the new row for the element
5813
                                    $origRecordRow = $this->recordInfo($table, $theUidToUpdate, '*');
5814
                                    if (is_array($origRecordRow)) {
5815
                                        BackendUtility::workspaceOL($table, $origRecordRow);
5816
                                        // Get current data structure and value array:
5817
                                        $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5818
                                            ['config' => $conf],
5819
                                            $table,
5820
                                            $fieldName,
5821
                                            $origRecordRow
5822
                                        );
5823
                                        $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5824
                                        $currentValueArray = GeneralUtility::xml2array($origRecordRow[$fieldName]);
5825
                                        // Do recursive processing of the XML data:
5826
                                        $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $theUidToUpdate, $fieldName], 'remapListedDBRecords_flexFormCallBack');
5827
                                        // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later):
5828
                                        if (is_array($currentValueArray['data'])) {
5829
                                            $newData[$fieldName] = $this->checkValue_flexArray2Xml($currentValueArray, true);
5830
                                        }
5831
                                    }
5832
                                }
5833
                                break;
5834
                            case 'inline':
5835
                                $this->remapListedDBRecords_procInline($conf, $value, $uid, $table);
5836
                                break;
5837
                            default:
5838
                                $this->logger->debug('Field type should not appear here: ' . $conf['type']);
5839
                        }
5840
                    }
5841
                    // If any fields were changed, those fields are updated!
5842
                    if (!empty($newData)) {
5843
                        $this->updateDB($table, $theUidToUpdate_saveTo, $newData);
5844
                    }
5845
                }
5846
            }
5847
        }
5848
    }
5849
5850
    /**
5851
     * Callback function for traversing the FlexForm structure in relation to creating copied files of file relations inside of flex form structures.
5852
     *
5853
     * @param array $pParams Set of parameters in numeric array: table, uid, field
5854
     * @param array $dsConf TCA config for field (from Data Structure of course)
5855
     * @param string $dataValue Field value (from FlexForm XML)
5856
     * @param string $dataValue_ext1 Not used
5857
     * @param string $dataValue_ext2 Not used
5858
     * @return array Array where the "value" key carries the value.
5859
     * @see checkValue_flex_procInData_travDS()
5860
     * @see remapListedDBRecords()
5861
     * @internal should only be used from within DataHandler
5862
     */
5863
    public function remapListedDBRecords_flexFormCallBack($pParams, $dsConf, $dataValue, $dataValue_ext1, $dataValue_ext2)
5864
    {
5865
        // Extract parameters:
5866
        [$table, $uid, $field] = $pParams;
5867
        // If references are set for this field, set flag so they can be corrected later:
5868
        if ($this->isReferenceField($dsConf) && (string)$dataValue !== '') {
5869
            $vArray = $this->remapListedDBRecords_procDBRefs($dsConf, $dataValue, $uid, $table);
5870
            if (is_array($vArray)) {
5871
                $dataValue = implode(',', $vArray);
5872
            }
5873
        }
5874
        // Return
5875
        return ['value' => $dataValue];
5876
    }
5877
5878
    /**
5879
     * Performs remapping of old UID values to NEW uid values for a DB reference field.
5880
     *
5881
     * @param array $conf TCA field config
5882
     * @param string $value Field value
5883
     * @param int $MM_localUid UID of local record (for MM relations - might need to change if support for FlexForms should be done!)
5884
     * @param string $table Table name
5885
     * @return array|null Returns array of items ready to implode for field content.
5886
     * @see remapListedDBRecords()
5887
     * @internal should only be used from within DataHandler
5888
     */
5889
    public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table)
5890
    {
5891
        // Initialize variables
5892
        // Will be set TRUE if an upgrade should be done...
5893
        $set = false;
5894
        // Allowed tables for references.
5895
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5896
        // Table name to prepend the UID
5897
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
5898
        // Which tables that should possibly not be remapped
5899
        $dontRemapTables = GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true);
5900
        // Convert value to list of references:
5901
        $dbAnalysis = $this->createRelationHandlerInstance();
5902
        $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false);
5903
        $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf);
5904
        // Traverse those references and map IDs:
5905
        foreach ($dbAnalysis->itemArray as $k => $v) {
5906
            $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0;
5907
            if ($mapID && !in_array($v['table'], $dontRemapTables, true)) {
5908
                $dbAnalysis->itemArray[$k]['id'] = $mapID;
5909
                $set = true;
5910
            }
5911
        }
5912
        if (!empty($conf['MM'])) {
5913
            // Purge invalid items (live/version)
5914
            $dbAnalysis->purgeItemArray();
5915
            if ($dbAnalysis->isPurged()) {
5916
                $set = true;
5917
            }
5918
5919
            // If record has been versioned/copied in this process, handle invalid relations of the live record
5920
            $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid);
5921
            $originalId = 0;
5922
            if (!empty($this->copyMappingArray_merged[$table])) {
5923
                $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]);
5924
            }
5925
            if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) {
5926
                $liveRelations = $this->createRelationHandlerInstance();
5927
                $liveRelations->setWorkspaceId(0);
5928
                $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf);
5929
                // Purge invalid relations in the live workspace ("0")
5930
                $liveRelations->purgeItemArray(0);
5931
                if ($liveRelations->isPurged()) {
5932
                    $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

5932
                    $liveRelations->writeMM($conf['MM'], $liveId, /** @scrutinizer ignore-type */ $prependName);
Loading history...
5933
                }
5934
            }
5935
        }
5936
        // If a change has been done, set the new value(s)
5937
        if ($set) {
5938
            if ($conf['MM'] ?? false) {
5939
                $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName);
5940
            } else {
5941
                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

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

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

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