DataHandler::checkRecordInsertAccess()   C
last analyzed

Complexity

Conditions 13
Paths 17

Size

Total Lines 37
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 24
dl 0
loc 37
rs 6.6166
c 0
b 0
f 0
cc 13
nc 17
nop 3

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

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

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

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

2384
                $statement->/** @scrutinizer ignore-call */ 
2385
                            bindValue(1, $newValue);
Loading history...
2385
                $statement->execute();
0 ignored issues
show
Bug introduced by
The method execute() does not exist on Doctrine\DBAL\Driver\ResultStatement. It seems like you code against a sub-type of Doctrine\DBAL\Driver\ResultStatement such as Doctrine\DBAL\Driver\Statement or Doctrine\DBAL\ForwardCompatibility\Result. ( Ignorable by Annotation )

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

2385
                $statement->/** @scrutinizer ignore-call */ 
2386
                            execute();
Loading history...
2386
                if (!$statement->fetchOne()) {
0 ignored issues
show
Bug introduced by
The method fetchOne() does not exist on Doctrine\DBAL\Driver\ResultStatement. Did you maybe mean fetch()? ( Ignorable by Annotation )

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

2386
                if (!$statement->/** @scrutinizer ignore-call */ fetchOne()) {

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...
2387
                    break;
2388
                }
2389
            }
2390
        }
2391
2392
        if ($originalValue !== $newValue) {
2393
            $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);
2394
        }
2395
2396
        return $newValue;
2397
    }
2398
2399
    /**
2400
     * Gets the count of records for a unique field
2401
     *
2402
     * @param string $value The string value which should be unique
2403
     * @param string $table Table name
2404
     * @param string $field Field name for which $value must be unique
2405
     * @param int $uid UID to filter out in the lookup (the record itself...)
2406
     * @param int $pid If set, the value will be unique for this PID
2407
     * @return QueryBuilder Return the prepared statement to check uniqueness
2408
     */
2409
    protected function getUniqueCountStatement(
2410
        string $value,
2411
        string $table,
2412
        string $field,
2413
        int $uid,
2414
        int $pid
2415
    ) {
2416
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
2417
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
2418
        $queryBuilder
2419
            ->count('uid')
2420
            ->from($table)
2421
            ->where(
2422
                $queryBuilder->expr()->eq($field, $queryBuilder->createPositionalParameter($value)),
2423
                $queryBuilder->expr()->neq('uid', $queryBuilder->createPositionalParameter($uid, \PDO::PARAM_INT))
2424
            );
2425
        // ignore translations of current record if field is configured with l10n_mode = "exclude"
2426
        if (($GLOBALS['TCA'][$table]['columns'][$field]['l10n_mode'] ?? '') === 'exclude'
2427
            && ($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? '') !== ''
2428
            && ($GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '') !== '') {
2429
            $queryBuilder
2430
                ->andWhere(
2431
                    $queryBuilder->expr()->orX(
2432
                    // records without l10n_parent must be taken into account (in any language)
2433
                        $queryBuilder->expr()->eq(
2434
                            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2435
                            $queryBuilder->createPositionalParameter(0, \PDO::PARAM_INT)
2436
                        ),
2437
                        // translations of other records must be taken into account
2438
                        $queryBuilder->expr()->neq(
2439
                            $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
2440
                            $queryBuilder->createPositionalParameter($uid, \PDO::PARAM_INT)
2441
                        )
2442
                    )
2443
                );
2444
        }
2445
        if ($pid !== 0) {
2446
            $queryBuilder->andWhere(
2447
                $queryBuilder->expr()->eq('pid', $queryBuilder->createPositionalParameter($pid, \PDO::PARAM_INT))
2448
            );
2449
        } else {
2450
            // pid>=0 for versioning
2451
            $queryBuilder->andWhere(
2452
                $queryBuilder->expr()->gte('pid', $queryBuilder->createPositionalParameter(0, \PDO::PARAM_INT))
2453
            );
2454
        }
2455
        return $queryBuilder;
2456
    }
2457
2458
    /**
2459
     * gets all records that have the same value in a field
2460
     * excluding the given uid
2461
     *
2462
     * @param string $tableName Table name
2463
     * @param int $uid UID to filter out in the lookup (the record itself...)
2464
     * @param string $fieldName Field name for which $value must be unique
2465
     * @param string $value Value string.
2466
     * @param int $pageId If set, the value will be unique for this PID
2467
     * @return array
2468
     * @internal should only be used from within DataHandler
2469
     */
2470
    public function getRecordsWithSameValue($tableName, $uid, $fieldName, $value, $pageId = 0)
2471
    {
2472
        $result = [];
2473
        if (empty($GLOBALS['TCA'][$tableName]['columns'][$fieldName])) {
2474
            return $result;
2475
        }
2476
2477
        $uid = (int)$uid;
2478
        $pageId = (int)$pageId;
2479
2480
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($tableName);
2481
        $queryBuilder->getRestrictions()
2482
            ->removeAll()
2483
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
2484
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
2485
2486
        $queryBuilder->select('*')
2487
            ->from($tableName)
2488
            ->where(
2489
                $queryBuilder->expr()->eq(
2490
                    $fieldName,
2491
                    $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR)
2492
                ),
2493
                $queryBuilder->expr()->neq(
2494
                    'uid',
2495
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
2496
                )
2497
            );
2498
2499
        if ($pageId) {
2500
            $queryBuilder->andWhere(
2501
                $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT))
2502
            );
2503
        }
2504
2505
        $result = $queryBuilder->execute()->fetchAllAssociative();
2506
2507
        return $result;
2508
    }
2509
2510
    /**
2511
     * @param string $value The field value to be evaluated
2512
     * @param array $evalArray Array of evaluations to traverse.
2513
     * @param string $is_in The "is_in" value of the field configuration from TCA
2514
     * @return array
2515
     * @internal should only be used from within DataHandler
2516
     */
2517
    public function checkValue_text_Eval($value, $evalArray, $is_in)
2518
    {
2519
        $res = [];
2520
        $set = true;
2521
        foreach ($evalArray as $func) {
2522
            switch ($func) {
2523
                case 'trim':
2524
                    $value = trim($value);
2525
                    break;
2526
                case 'required':
2527
                    if (!$value) {
2528
                        $set = false;
2529
                    }
2530
                    break;
2531
                default:
2532
                    if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2533
                        if (class_exists($func)) {
2534
                            $evalObj = GeneralUtility::makeInstance($func);
2535
                            if (method_exists($evalObj, 'evaluateFieldValue')) {
2536
                                $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2537
                            }
2538
                        }
2539
                    }
2540
            }
2541
        }
2542
        if ($set) {
2543
            $res['value'] = $value;
2544
        }
2545
        return $res;
2546
    }
2547
2548
    /**
2549
     * Evaluation of 'input'-type values based on 'eval' list
2550
     *
2551
     * @param string $value Value to evaluate
2552
     * @param array $evalArray Array of evaluations to traverse.
2553
     * @param string $is_in Is-in string for 'is_in' evaluation
2554
     * @param string $table Table name the eval is evaluated on
2555
     * @param string|int $id Record ID the eval is evaluated on
2556
     * @return array Modified $value in key 'value' or empty array
2557
     * @internal should only be used from within DataHandler
2558
     */
2559
    public function checkValue_input_Eval($value, $evalArray, $is_in, string $table = '', $id = ''): array
2560
    {
2561
        $res = [];
2562
        $set = true;
2563
        foreach ($evalArray as $func) {
2564
            switch ($func) {
2565
                case 'int':
2566
                case 'year':
2567
                    $value = (int)$value;
2568
                    break;
2569
                case 'time':
2570
                case 'timesec':
2571
                    // If $value is a pure integer we have the number of seconds, we can store that directly
2572
                    if ($value !== '' && !MathUtility::canBeInterpretedAsInteger($value)) {
2573
                        // $value is an ISO 8601 date
2574
                        $value = (new \DateTime($value))->getTimestamp();
2575
                    }
2576
                    break;
2577
                case 'date':
2578
                case 'datetime':
2579
                    // If $value is a pure integer we have the number of seconds, we can store that directly
2580
                    if ($value !== null && $value !== '' && !MathUtility::canBeInterpretedAsInteger($value)) {
2581
                        // The value we receive from JS is an ISO 8601 date, which is always in UTC. (the JS code works like that, on purpose!)
2582
                        // For instance "1999-11-11T11:11:11Z"
2583
                        // Since the user actually specifies the time in the server's local time, we need to mangle this
2584
                        // to reflect the server TZ. So we make this 1999-11-11T11:11:11+0200 (assuming Europe/Vienna here)
2585
                        // In the database we store the date in UTC (1999-11-11T09:11:11Z), hence we take the timestamp of this converted value.
2586
                        // For achieving this we work with timestamps only (which are UTC) and simply adjust it for the
2587
                        // TZ difference.
2588
                        try {
2589
                            // Make the date from JS a timestamp
2590
                            $value = (new \DateTime($value))->getTimestamp();
2591
                        } catch (\Exception $e) {
2592
                            // set the default timezone value to achieve the value of 0 as a result
2593
                            $value = (int)date('Z', 0);
2594
                        }
2595
2596
                        // @todo this hacky part is problematic when it comes to times around DST switch! Add test to prove that this is broken.
2597
                        $value -= date('Z', $value);
2598
                    }
2599
                    break;
2600
                case 'double2':
2601
                    $value = preg_replace('/[^0-9,\\.-]/', '', $value);
2602
                    $negative = substr($value, 0, 1) === '-';
2603
                    $value = strtr($value, [',' => '.', '-' => '']);
2604
                    if (!str_contains($value, '.')) {
2605
                        $value .= '.0';
2606
                    }
2607
                    $valueArray = explode('.', $value);
2608
                    $dec = array_pop($valueArray);
2609
                    $value = implode('', $valueArray) . '.' . $dec;
2610
                    if ($negative) {
2611
                        $value *= -1;
2612
                    }
2613
                    $value = number_format((float)$value, 2, '.', '');
2614
                    break;
2615
                case 'md5':
2616
                    if (strlen($value) !== 32) {
2617
                        $set = false;
2618
                    }
2619
                    break;
2620
                case 'trim':
2621
                    $value = trim($value);
2622
                    break;
2623
                case 'upper':
2624
                    $value = mb_strtoupper($value, 'utf-8');
2625
                    break;
2626
                case 'lower':
2627
                    $value = mb_strtolower($value, 'utf-8');
2628
                    break;
2629
                case 'required':
2630
                    if (!isset($value) || $value === '') {
2631
                        $set = false;
2632
                    }
2633
                    break;
2634
                case 'is_in':
2635
                    $c = mb_strlen($value);
2636
                    if ($c) {
2637
                        $newVal = '';
2638
                        for ($a = 0; $a < $c; $a++) {
2639
                            $char = mb_substr($value, $a, 1);
2640
                            if (str_contains($is_in, $char)) {
2641
                                $newVal .= $char;
2642
                            }
2643
                        }
2644
                        $value = $newVal;
2645
                    }
2646
                    break;
2647
                case 'nospace':
2648
                    $value = str_replace(' ', '', $value);
2649
                    break;
2650
                case 'alpha':
2651
                    $value = preg_replace('/[^a-zA-Z]/', '', $value);
2652
                    break;
2653
                case 'num':
2654
                    $value = preg_replace('/[^0-9]/', '', $value);
2655
                    break;
2656
                case 'alphanum':
2657
                    $value = preg_replace('/[^a-zA-Z0-9]/', '', $value);
2658
                    break;
2659
                case 'alphanum_x':
2660
                    $value = preg_replace('/[^a-zA-Z0-9_-]/', '', $value);
2661
                    break;
2662
                case 'domainname':
2663
                    if (!preg_match('/^[a-z0-9.\\-]*$/i', $value)) {
2664
                        $value = (string)idn_to_ascii($value);
2665
                    }
2666
                    break;
2667
                case 'email':
2668
                    if ((string)$value !== '') {
2669
                        $this->checkValue_input_ValidateEmail($value, $set, $table, $id);
2670
                    }
2671
                    break;
2672
                case 'saltedPassword':
2673
                    // An incoming value is either the salted password if the user did not change existing password
2674
                    // when submitting the form, or a plaintext new password that needs to be turned into a salted password now.
2675
                    // The strategy is to see if a salt instance can be created from the incoming value. If so,
2676
                    // no new password was submitted and we keep the value. If no salting instance can be created,
2677
                    // incoming value must be a new plain text value that needs to be hashed.
2678
                    $hashFactory = GeneralUtility::makeInstance(PasswordHashFactory::class);
2679
                    $mode = $table === 'fe_users' ? 'FE' : 'BE';
2680
                    try {
2681
                        $hashFactory->get($value, $mode);
2682
                    } catch (InvalidPasswordHashException $e) {
2683
                        // We got no salted password instance, incoming value must be a new plaintext password
2684
                        // Get an instance of the current configured salted password strategy and hash the value
2685
                        $newHashInstance = $hashFactory->getDefaultHashInstance($mode);
2686
                        $value = $newHashInstance->getHashedPassword($value);
2687
                    }
2688
                    break;
2689
                default:
2690
                    if (isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][$func])) {
2691
                        if (class_exists($func)) {
2692
                            $evalObj = GeneralUtility::makeInstance($func);
2693
                            if (method_exists($evalObj, 'evaluateFieldValue')) {
2694
                                $value = $evalObj->evaluateFieldValue($value, $is_in, $set);
2695
                            }
2696
                        }
2697
                    }
2698
            }
2699
        }
2700
        if ($set) {
2701
            $res['value'] = $value;
2702
        }
2703
        return $res;
2704
    }
2705
2706
    /**
2707
     * If $value is not a valid e-mail address,
2708
     * $set will be set to false and a flash error
2709
     * message will be added
2710
     *
2711
     * @param string $value Value to evaluate
2712
     * @param bool $set TRUE if an update should be done
2713
     * @throws \InvalidArgumentException
2714
     * @throws \TYPO3\CMS\Core\Exception
2715
     */
2716
    protected function checkValue_input_ValidateEmail($value, &$set, string $table, $id)
2717
    {
2718
        if (GeneralUtility::validEmail($value)) {
2719
            return;
2720
        }
2721
2722
        $set = false;
2723
        $this->log(
2724
            $table,
2725
            $id,
2726
            SystemLogDatabaseAction::UPDATE,
2727
            0,
2728
            SystemLogErrorClassification::SECURITY_NOTICE,
2729
            '"' . $value . '" is not a valid e-mail address.',
2730
            -1,
2731
            [$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:error.invalidEmail'), $value]
2732
        );
2733
    }
2734
2735
    /**
2736
     * Returns processed data for category fields
2737
     *
2738
     * @param array $valueArray Current value array
2739
     * @param array $tcaFieldConf TCA field config
2740
     * @param string|int $id Record id, used for look-up of MM relations (local_uid)
2741
     * @param string $status Status string ('update' or 'new')
2742
     * @param string $table Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler
2743
     * @param string $field field name, needs to be set for writing to sys_history
2744
     * @return array Modified value array
2745
     * @internal should only be used from within DataHandler
2746
     */
2747
    public function checkValue_category_processDBdata(
2748
        array $valueArray,
2749
        array $tcaFieldConf,
2750
        $id,
2751
        string $status,
2752
        string $table,
2753
        string $field
2754
    ): array {
2755
        $newRelations = implode(',', $valueArray);
2756
        $relationHandler = $this->createRelationHandlerInstance();
2757
        $relationHandler->start($newRelations, $tcaFieldConf['foreign_table'], '', 0, $table, $tcaFieldConf);
2758
        if ($tcaFieldConf['MM'] ?? false) {
2759
            $relationHandler->convertItemArray();
2760
            if ($status === 'update') {
2761
                $relationHandleForOldRelations = $this->createRelationHandlerInstance();
2762
                $relationHandleForOldRelations->start('', $tcaFieldConf['foreign_table'], $tcaFieldConf['MM'], $id, $table, $tcaFieldConf);
2763
                $oldRelations = implode(',', $relationHandleForOldRelations->getValueArray());
2764
                $relationHandler->writeMM($tcaFieldConf['MM'], $id);
0 ignored issues
show
Bug introduced by
It seems like $id can also be of type string; however, parameter $uid of TYPO3\CMS\Core\Database\RelationHandler::writeMM() 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

2764
                $relationHandler->writeMM($tcaFieldConf['MM'], /** @scrutinizer ignore-type */ $id);
Loading history...
2765
                if ($oldRelations !== $newRelations) {
2766
                    $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = $oldRelations;
2767
                    $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = $newRelations;
2768
                } else {
2769
                    $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$field] = '';
2770
                    $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$field] = '';
2771
                }
2772
            } else {
2773
                $this->dbAnalysisStore[] = [$relationHandler, $tcaFieldConf['MM'], $id, '', $table];
2774
            }
2775
            $valueArray = $relationHandler->countItems();
2776
        } else {
2777
            $valueArray = $relationHandler->getValueArray();
2778
        }
2779
        return $valueArray;
2780
    }
2781
2782
    /**
2783
     * Returns data for group/db and select fields
2784
     *
2785
     * @param array $valueArray Current value array
2786
     * @param array $tcaFieldConf TCA field config
2787
     * @param int $id Record id, used for look-up of MM relations (local_uid)
2788
     * @param string $status Status string ('update' or 'new')
2789
     * @param string $type The type, either 'select', 'group' or 'inline'
2790
     * @param string $currentTable Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler
2791
     * @param string $currentField field name, needs to be set for writing to sys_history
2792
     * @return array Modified value array
2793
     * @internal should only be used from within DataHandler
2794
     */
2795
    public function checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, $type, $currentTable, $currentField)
2796
    {
2797
        $tables = $type === 'group' ? $tcaFieldConf['allowed'] : $tcaFieldConf['foreign_table'];
2798
        $prep = $type === 'group' ? ($tcaFieldConf['prepend_tname'] ?? '') : '';
2799
        $newRelations = implode(',', $valueArray);
2800
        /** @var RelationHandler $dbAnalysis */
2801
        $dbAnalysis = $this->createRelationHandlerInstance();
2802
        $dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2803
        $dbAnalysis->start($newRelations, $tables, '', 0, $currentTable, $tcaFieldConf);
2804
        if ($tcaFieldConf['MM'] ?? false) {
2805
            // convert submitted items to use version ids instead of live ids
2806
            // (only required for MM relations in a workspace context)
2807
            $dbAnalysis->convertItemArray();
2808
            if ($status === 'update') {
2809
                /** @var RelationHandler $oldRelations_dbAnalysis */
2810
                $oldRelations_dbAnalysis = $this->createRelationHandlerInstance();
2811
                $oldRelations_dbAnalysis->registerNonTableValues = !empty($tcaFieldConf['allowNonIdValues']);
2812
                // Db analysis with $id will initialize with the existing relations
2813
                $oldRelations_dbAnalysis->start('', $tables, $tcaFieldConf['MM'], $id, $currentTable, $tcaFieldConf);
2814
                $oldRelations = implode(',', $oldRelations_dbAnalysis->getValueArray());
2815
                $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

2815
                $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, /** @scrutinizer ignore-type */ $prep);
Loading history...
2816
                if ($oldRelations != $newRelations) {
2817
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
2818
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
2819
                } else {
2820
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
2821
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
2822
                }
2823
            } else {
2824
                $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
2825
            }
2826
            $valueArray = $dbAnalysis->countItems();
2827
        } else {
2828
            $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

2828
            $valueArray = $dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prep);
Loading history...
2829
        }
2830
        // 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.
2831
        return $valueArray;
2832
    }
2833
2834
    /**
2835
     * Explodes the $value, which is a list of files/uids (group select)
2836
     *
2837
     * @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.
2838
     * @return array The value array.
2839
     * @internal should only be used from within DataHandler
2840
     */
2841
    public function checkValue_group_select_explodeSelectGroupValue($value)
2842
    {
2843
        $valueArray = GeneralUtility::trimExplode(',', $value, true);
2844
        foreach ($valueArray as &$newVal) {
2845
            $temp = explode('|', $newVal, 2);
2846
            $newVal = str_replace(['|', ','], '', rawurldecode($temp[0]));
2847
        }
2848
        unset($newVal);
2849
        return $valueArray;
2850
    }
2851
2852
    /**
2853
     * Starts the processing the input data for flexforms. This will traverse all sheets / languages and for each it will traverse the sub-structure.
2854
     * See checkValue_flex_procInData_travDS() for more details.
2855
     * 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
2856
     *
2857
     * @param array $dataPart The 'data' part of the INPUT flexform data
2858
     * @param array $dataPart_current The 'data' part of the CURRENT flexform data
2859
     * @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.
2860
     * @param array $pParams A set of parameters to pass through for the calling of the evaluation functions
2861
     * @param string $callBackFunc Optional call back function, see checkValue_flex_procInData_travDS()  DEPRECATED, use \TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools instead for traversal!
2862
     * @param array $workspaceOptions
2863
     * @return array The modified 'data' part.
2864
     * @see checkValue_flex_procInData_travDS()
2865
     * @internal should only be used from within DataHandler
2866
     */
2867
    public function checkValue_flex_procInData($dataPart, $dataPart_current, $dataStructure, $pParams, $callBackFunc = '', array $workspaceOptions = [])
2868
    {
2869
        if (is_array($dataPart)) {
0 ignored issues
show
introduced by
The condition is_array($dataPart) is always true.
Loading history...
2870
            foreach ($dataPart as $sKey => $sheetDef) {
2871
                if (isset($dataStructure['sheets'][$sKey]) && is_array($dataStructure['sheets'][$sKey]) && is_array($sheetDef)) {
2872
                    foreach ($sheetDef as $lKey => $lData) {
2873
                        $this->checkValue_flex_procInData_travDS(
2874
                            $dataPart[$sKey][$lKey],
2875
                            $dataPart_current[$sKey][$lKey] ?? null,
2876
                            $dataStructure['sheets'][$sKey]['ROOT']['el'] ?? null,
2877
                            $pParams,
2878
                            $callBackFunc,
2879
                            $sKey . '/' . $lKey . '/',
2880
                            $workspaceOptions
2881
                        );
2882
                    }
2883
                }
2884
            }
2885
        }
2886
        return $dataPart;
2887
    }
2888
2889
    /**
2890
     * Processing of the sheet/language data array
2891
     * 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.
2892
     *
2893
     * @param array $dataValues New values (those being processed): Multidimensional Data array for sheet/language, passed by reference!
2894
     * @param array $dataValues_current Current values: Multidimensional Data array. May be empty array() if not needed (for callBackFunctions)
2895
     * @param array $DSelements Data structure which fits the data array
2896
     * @param array $pParams A set of parameters to pass through for the calling of the evaluation functions / call back function
2897
     * @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.
2898
     * @param string $structurePath
2899
     * @param array $workspaceOptions
2900
     * @see checkValue_flex_procInData()
2901
     * @internal should only be used from within DataHandler
2902
     */
2903
    public function checkValue_flex_procInData_travDS(&$dataValues, $dataValues_current, $DSelements, $pParams, $callBackFunc, $structurePath, array $workspaceOptions = [])
2904
    {
2905
        if (!is_array($DSelements)) {
0 ignored issues
show
introduced by
The condition is_array($DSelements) is always true.
Loading history...
2906
            return;
2907
        }
2908
2909
        // For each DS element:
2910
        foreach ($DSelements as $key => $dsConf) {
2911
            // Array/Section:
2912
            if (isset($DSelements[$key]['type']) && $DSelements[$key]['type'] === 'array') {
2913
                if (!is_array($dataValues[$key]['el'])) {
2914
                    continue;
2915
                }
2916
2917
                if ($DSelements[$key]['section']) {
2918
                    foreach ($dataValues[$key]['el'] as $ik => $el) {
2919
                        if (!is_array($el)) {
2920
                            continue;
2921
                        }
2922
2923
                        if (!is_array($dataValues_current[$key]['el'] ?? false)) {
2924
                            $dataValues_current[$key]['el'] = [];
2925
                        }
2926
                        $theKey = key($el);
2927
                        if (!is_array($dataValues[$key]['el'][$ik][$theKey]['el'])) {
2928
                            continue;
2929
                        }
2930
2931
                        $this->checkValue_flex_procInData_travDS(
2932
                            $dataValues[$key]['el'][$ik][$theKey]['el'],
2933
                            $dataValues_current[$key]['el'][$ik][$theKey]['el'] ?? [],
2934
                            $DSelements[$key]['el'][$theKey]['el'] ?? [],
2935
                            $pParams,
2936
                            $callBackFunc,
2937
                            $structurePath . $key . '/el/' . $ik . '/' . $theKey . '/el/',
2938
                            $workspaceOptions
2939
                        );
2940
                    }
2941
                } else {
2942
                    if (!isset($dataValues[$key]['el'])) {
2943
                        $dataValues[$key]['el'] = [];
2944
                    }
2945
                    $this->checkValue_flex_procInData_travDS($dataValues[$key]['el'], $dataValues_current[$key]['el'], $DSelements[$key]['el'], $pParams, $callBackFunc, $structurePath . $key . '/el/', $workspaceOptions);
2946
                }
2947
            } else {
2948
                // When having no specific sheets, it's "TCEforms.config", when having a sheet, it's just "config"
2949
                $fieldConfiguration = $dsConf['TCEforms']['config'] ?? $dsConf['config'] ?? null;
2950
                // init with value from config for passthrough fields
2951
                if (!empty($fieldConfiguration['type']) && $fieldConfiguration['type'] === 'passthrough') {
2952
                    if (!empty($dataValues_current[$key]['vDEF'])) {
2953
                        // If there is existing value, keep it
2954
                        $dataValues[$key]['vDEF'] = $dataValues_current[$key]['vDEF'];
2955
                    } elseif (
2956
                        !empty($fieldConfiguration['default'])
2957
                        && isset($pParams[1])
2958
                        && !MathUtility::canBeInterpretedAsInteger($pParams[1])
2959
                    ) {
2960
                        // If is new record and a default is specified for field, use it.
2961
                        $dataValues[$key]['vDEF'] = $fieldConfiguration['default'];
2962
                    }
2963
                }
2964
                if (!is_array($fieldConfiguration) || !isset($dataValues[$key]) || !is_array($dataValues[$key])) {
2965
                    continue;
2966
                }
2967
2968
                foreach ($dataValues[$key] as $vKey => $data) {
2969
                    if ($callBackFunc) {
2970
                        if (is_object($this->callBackObj)) {
2971
                            $res = $this->callBackObj->{$callBackFunc}(
2972
                                $pParams,
2973
                                $fieldConfiguration,
2974
                                $dataValues[$key][$vKey] ?? null,
2975
                                $dataValues_current[$key][$vKey] ?? null,
2976
                                $structurePath . $key . '/' . $vKey . '/',
2977
                                $workspaceOptions
2978
                            );
2979
                        } else {
2980
                            $res = $this->{$callBackFunc}(
2981
                                $pParams,
2982
                                $fieldConfiguration,
2983
                                $dataValues[$key][$vKey] ?? null,
2984
                                $dataValues_current[$key][$vKey] ?? null,
2985
                                $structurePath . $key . '/' . $vKey . '/',
2986
                                $workspaceOptions
2987
                            );
2988
                        }
2989
                    } else {
2990
                        // Default
2991
                        [$CVtable, $CVid, $CVcurValue, $CVstatus, $CVrealPid, $CVrecFID, $CVtscPID] = $pParams;
2992
2993
                        $additionalData = [
2994
                            'flexFormId' => $CVrecFID,
2995
                            'flexFormPath' => trim(rtrim($structurePath, '/') . '/' . $key . '/' . $vKey, '/'),
2996
                        ];
2997
2998
                        $res = $this->checkValue_SW(
2999
                            [],
3000
                            $dataValues[$key][$vKey] ?? null,
3001
                            $fieldConfiguration,
3002
                            $CVtable,
3003
                            $CVid,
3004
                            $dataValues_current[$key][$vKey] ?? null,
3005
                            $CVstatus,
3006
                            $CVrealPid,
3007
                            $CVrecFID,
3008
                            '',
3009
                            $CVtscPID,
3010
                            $additionalData
3011
                        );
3012
                    }
3013
                    // Adding the value:
3014
                    if (isset($res['value'])) {
3015
                        $dataValues[$key][$vKey] = $res['value'];
3016
                    }
3017
                }
3018
            }
3019
        }
3020
    }
3021
3022
    /**
3023
     * Returns data for inline fields.
3024
     *
3025
     * @param array $valueArray Current value array
3026
     * @param array $tcaFieldConf TCA field config
3027
     * @param int $id Record id
3028
     * @param string $status Status string ('update' or 'new')
3029
     * @param string $table Table name, needs to be passed to \TYPO3\CMS\Core\Database\RelationHandler
3030
     * @param string $field The current field the values are modified for
3031
     * @return string Modified values
3032
     */
3033
    protected function checkValue_inline_processDBdata($valueArray, $tcaFieldConf, $id, $status, $table, $field)
3034
    {
3035
        $foreignTable = $tcaFieldConf['foreign_table'];
3036
        $valueArray = $this->applyFiltersToValues($tcaFieldConf, $valueArray);
3037
        // Fetch the related child records using \TYPO3\CMS\Core\Database\RelationHandler
3038
        /** @var RelationHandler $dbAnalysis */
3039
        $dbAnalysis = $this->createRelationHandlerInstance();
3040
        $dbAnalysis->start(implode(',', $valueArray), $foreignTable, '', 0, $table, $tcaFieldConf);
3041
        // IRRE with a pointer field (database normalization):
3042
        if ($tcaFieldConf['foreign_field'] ?? false) {
3043
            // update record in intermediate table (sorting & pointer uid to parent record)
3044
            $dbAnalysis->writeForeignField($tcaFieldConf, $id, 0);
0 ignored issues
show
Deprecated Code introduced by
The function TYPO3\CMS\Core\Database\...er::writeForeignField() has been deprecated. ( Ignorable by Annotation )

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

3044
            /** @scrutinizer ignore-deprecated */ $dbAnalysis->writeForeignField($tcaFieldConf, $id, 0);
Loading history...
3045
            $newValue = $dbAnalysis->countItems(false);
3046
        } elseif ($this->getInlineFieldType($tcaFieldConf) === 'mm') {
3047
            // In order to fully support all the MM stuff, directly call checkValue_group_select_processDBdata instead of repeating the needed code here
3048
            $valueArray = $this->checkValue_group_select_processDBdata($valueArray, $tcaFieldConf, $id, $status, 'select', $table, $field);
3049
            $newValue = $valueArray[0];
3050
        } else {
3051
            $valueArray = $dbAnalysis->getValueArray();
3052
            // Checking that the number of items is correct:
3053
            $valueArray = $this->checkValue_checkMax($tcaFieldConf, $valueArray);
3054
            $newValue = $this->castReferenceValue(implode(',', $valueArray), $tcaFieldConf);
3055
        }
3056
        return $newValue;
3057
    }
3058
3059
    /*********************************************
3060
     *
3061
     * PROCESSING COMMANDS
3062
     *
3063
     ********************************************/
3064
    /**
3065
     * Processing the cmd-array
3066
     * See "TYPO3 Core API" for a description of the options.
3067
     *
3068
     * @return void|bool
3069
     */
3070
    public function process_cmdmap()
3071
    {
3072
        // Editing frozen:
3073
        if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
3074
            $this->log('sys_workspace', $this->BE_USER->workspace, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'All editing in this workspace has been frozen!');
3075
            return false;
3076
        }
3077
        // Hook initialization:
3078
        $hookObjectsArr = [];
3079
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
3080
            $hookObj = GeneralUtility::makeInstance($className);
3081
            if (method_exists($hookObj, 'processCmdmap_beforeStart')) {
3082
                $hookObj->processCmdmap_beforeStart($this);
3083
            }
3084
            $hookObjectsArr[] = $hookObj;
3085
        }
3086
        $pasteDatamap = [];
3087
        // Traverse command map:
3088
        foreach ($this->cmdmap as $table => $_) {
3089
            // Check if the table may be modified!
3090
            $modifyAccessList = $this->checkModifyAccessList($table);
3091
            if (!$modifyAccessList) {
3092
                $this->log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table \'%s\' without permission', 1, [$table]);
3093
            }
3094
            // Check basic permissions and circumstances:
3095
            if (!isset($GLOBALS['TCA'][$table]) || $this->tableReadOnly($table) || !is_array($this->cmdmap[$table]) || !$modifyAccessList) {
3096
                continue;
3097
            }
3098
3099
            // Traverse the command map:
3100
            foreach ($this->cmdmap[$table] as $id => $incomingCmdArray) {
3101
                if (!is_array($incomingCmdArray)) {
3102
                    continue;
3103
                }
3104
3105
                if ($table === 'pages') {
3106
                    // for commands on pages do a pagetree-refresh
3107
                    $this->pagetreeNeedsRefresh = true;
3108
                }
3109
3110
                foreach ($incomingCmdArray as $command => $value) {
3111
                    $pasteUpdate = false;
3112
                    if (is_array($value) && isset($value['action']) && $value['action'] === 'paste') {
3113
                        // Extended paste command: $command is set to "move" or "copy"
3114
                        // $value['update'] holds field/value pairs which should be updated after copy/move operation
3115
                        // $value['target'] holds original $value (target of move/copy)
3116
                        $pasteUpdate = $value['update'];
3117
                        $value = $value['target'];
3118
                    }
3119
                    foreach ($hookObjectsArr as $hookObj) {
3120
                        if (method_exists($hookObj, 'processCmdmap_preProcess')) {
3121
                            $hookObj->processCmdmap_preProcess($command, $table, $id, $value, $this, $pasteUpdate);
3122
                        }
3123
                    }
3124
                    // Init copyMapping array:
3125
                    // Must clear this array before call from here to those functions:
3126
                    // Contains mapping information between new and old id numbers.
3127
                    $this->copyMappingArray = [];
3128
                    // process the command
3129
                    $commandIsProcessed = false;
3130
                    foreach ($hookObjectsArr as $hookObj) {
3131
                        if (method_exists($hookObj, 'processCmdmap')) {
3132
                            $hookObj->processCmdmap($command, $table, $id, $value, $commandIsProcessed, $this, $pasteUpdate);
3133
                        }
3134
                    }
3135
                    // Only execute default commands if a hook hasn't been processed the command already
3136
                    if (!$commandIsProcessed) {
3137
                        $procId = $id;
3138
                        $backupUseTransOrigPointerField = $this->useTransOrigPointerField;
3139
                        // Branch, based on command
3140
                        switch ($command) {
3141
                            case 'move':
3142
                                $this->moveRecord($table, (int)$id, $value);
3143
                                break;
3144
                            case 'copy':
3145
                                $target = $value['target'] ?? $value;
3146
                                $ignoreLocalization = (bool)($value['ignoreLocalization'] ?? false);
3147
                                if ($table === 'pages') {
3148
                                    $this->copyPages((int)$id, $target);
3149
                                } else {
3150
                                    $this->copyRecord($table, (int)$id, $target, true, [], '', 0, $ignoreLocalization);
3151
                                }
3152
                                $procId = $this->copyMappingArray[$table][$id];
3153
                                break;
3154
                            case 'localize':
3155
                                $this->useTransOrigPointerField = true;
3156
                                $this->localize($table, (int)$id, $value);
3157
                                break;
3158
                            case 'copyToLanguage':
3159
                                $this->useTransOrigPointerField = false;
3160
                                $this->localize($table, (int)$id, $value);
3161
                                break;
3162
                            case 'inlineLocalizeSynchronize':
3163
                                $this->inlineLocalizeSynchronize($table, (int)$id, $value);
3164
                                break;
3165
                            case 'delete':
3166
                                $this->deleteAction($table, (int)$id);
3167
                                break;
3168
                            case 'undelete':
3169
                                $this->undeleteRecord((string)$table, (int)$id);
3170
                                break;
3171
                        }
3172
                        $this->useTransOrigPointerField = $backupUseTransOrigPointerField;
3173
                        if (is_array($pasteUpdate)) {
3174
                            $pasteDatamap[$table][$procId] = $pasteUpdate;
3175
                        }
3176
                    }
3177
                    foreach ($hookObjectsArr as $hookObj) {
3178
                        if (method_exists($hookObj, 'processCmdmap_postProcess')) {
3179
                            $hookObj->processCmdmap_postProcess($command, $table, $id, $value, $this, $pasteUpdate, $pasteDatamap);
3180
                        }
3181
                    }
3182
                    // Merging the copy-array info together for remapping purposes.
3183
                    ArrayUtility::mergeRecursiveWithOverrule($this->copyMappingArray_merged, $this->copyMappingArray);
3184
                }
3185
            }
3186
        }
3187
        /** @var DataHandler $copyTCE */
3188
        $copyTCE = $this->getLocalTCE();
3189
        $copyTCE->start($pasteDatamap, [], $this->BE_USER);
3190
        $copyTCE->process_datamap();
3191
        $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3192
        unset($copyTCE);
3193
3194
        // Finally, before exit, check if there are ID references to remap.
3195
        // This might be the case if versioning or copying has taken place!
3196
        $this->remapListedDBRecords();
3197
        $this->processRemapStack();
3198
        foreach ($hookObjectsArr as $hookObj) {
3199
            if (method_exists($hookObj, 'processCmdmap_afterFinish')) {
3200
                $hookObj->processCmdmap_afterFinish($this);
3201
            }
3202
        }
3203
        if ($this->isOuterMostInstance()) {
3204
            $this->referenceIndexUpdater->update();
3205
            $this->processClearCacheQueue();
3206
            $this->resetNestedElementCalls();
3207
        }
3208
    }
3209
3210
    /*********************************************
3211
     *
3212
     * Cmd: Copying
3213
     *
3214
     ********************************************/
3215
    /**
3216
     * Copying a single record
3217
     *
3218
     * @param string $table Element table
3219
     * @param int $uid Element UID
3220
     * @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
3221
     * @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
3222
     * @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!
3223
     * @param string $excludeFields Commalist of fields to exclude from the copy process (might get default values)
3224
     * @param int $language Language ID (from sys_language table)
3225
     * @param bool $ignoreLocalization If TRUE, any localization routine is skipped
3226
     * @return int|null ID of new record, if any
3227
     * @internal should only be used from within DataHandler
3228
     */
3229
    public function copyRecord($table, $uid, $destPid, $first = false, $overrideValues = [], $excludeFields = '', $language = 0, $ignoreLocalization = false)
3230
    {
3231
        $uid = ($origUid = (int)$uid);
3232
        // Only copy if the table is defined in $GLOBALS['TCA'], a uid is given and the record wasn't copied before:
3233
        if (empty($GLOBALS['TCA'][$table]) || $uid === 0) {
3234
            return null;
3235
        }
3236
        if ($this->isRecordCopied($table, $uid)) {
3237
            return null;
3238
        }
3239
3240
        // Fetch record with permission check
3241
        $row = $this->recordInfoWithPermissionCheck($table, $uid, Permission::PAGE_SHOW);
3242
3243
        // This checks if the record can be selected which is all that a copy action requires.
3244
        if ($row === false) {
3245
            $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]);
3246
            return null;
3247
        }
3248
3249
        // 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...
3250
        $tscPID = (int)BackendUtility::getTSconfig_pidValue($table, $uid, $destPid);
3251
3252
        // Check if table is allowed on destination page
3253
        if (!$this->isTableAllowedForThisPage($tscPID, $table)) {
3254
            $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]);
3255
            return null;
3256
        }
3257
3258
        $fullLanguageCheckNeeded = $table !== 'pages';
3259
        // Used to check language and general editing rights
3260
        if (!$ignoreLocalization && ($language <= 0 || !$this->BE_USER->checkLanguageAccess($language)) && !$this->BE_USER->recordEditAccessInternals($table, $uid, false, false, $fullLanguageCheckNeeded)) {
3261
            $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]);
3262
            return null;
3263
        }
3264
3265
        $data = [];
3266
        $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));
3267
        BackendUtility::workspaceOL($table, $row, $this->BE_USER->workspace);
3268
        $row = BackendUtility::purgeComputedPropertiesFromRecord($row);
3269
3270
        // Initializing:
3271
        $theNewID = StringUtility::getUniqueId('NEW');
3272
        $enableField = isset($GLOBALS['TCA'][$table]['ctrl']['enablecolumns']) ? $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'] : '';
3273
        $headerField = $GLOBALS['TCA'][$table]['ctrl']['label'];
3274
        // Getting "copy-after" fields if applicable:
3275
        $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

3275
        $copyAfterFields = $destPid < 0 ? $this->fixCopyAfterDuplFields($table, $uid, /** @scrutinizer ignore-type */ abs($destPid), false) : [];
Loading history...
3276
        // Page TSconfig related:
3277
        $TSConfig = BackendUtility::getPagesTSconfig($tscPID)['TCEMAIN.'] ?? [];
3278
        $tE = $this->getTableEntries($table, $TSConfig);
3279
        // Traverse ALL fields of the selected record:
3280
        foreach ($row as $field => $value) {
3281
            if (!in_array($field, $nonFields, true)) {
3282
                // Get TCA configuration for the field:
3283
                $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? [];
3284
                // Preparation/Processing of the value:
3285
                // "pid" is hardcoded of course:
3286
                // isset() won't work here, since values can be NULL in each of the arrays
3287
                // except setDefaultOnCopyArray, since we exploded that from a string
3288
                if ($field === 'pid') {
3289
                    $value = $destPid;
3290
                } elseif (array_key_exists($field, $overrideValues)) {
3291
                    // Override value...
3292
                    $value = $overrideValues[$field];
3293
                } elseif (array_key_exists($field, $copyAfterFields)) {
3294
                    // Copy-after value if available:
3295
                    $value = $copyAfterFields[$field];
3296
                } else {
3297
                    // Hide at copy may override:
3298
                    if ($first && $field == $enableField
3299
                        && ($GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
3300
                        && !$this->neverHideAtCopy
3301
                        && !($tE['disableHideAtCopy'] ?? false)
3302
                    ) {
3303
                        $value = 1;
3304
                    }
3305
                    // Prepend label on copy:
3306
                    if ($first && $field == $headerField
3307
                        && ($GLOBALS['TCA'][$table]['ctrl']['prependAtCopy'] ?? false)
3308
                        && !($tE['disablePrependAtCopy'] ?? false)
3309
                    ) {
3310
                        $value = $this->getCopyHeader($table, $this->resolvePid($table, $destPid), $field, $this->clearPrefixFromValue($table, $value), 0);
3311
                    }
3312
                    // Processing based on the TCA config field type (files, references, flexforms...)
3313
                    $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $tscPID, $language);
3314
                }
3315
                // Add value to array.
3316
                $data[$table][$theNewID][$field] = $value;
3317
            }
3318
        }
3319
        // Overriding values:
3320
        if ($GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
3321
            $data[$table][$theNewID][$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
3322
        }
3323
        // Setting original UID:
3324
        if ($GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? false) {
3325
            $data[$table][$theNewID][$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3326
        }
3327
        // Do the copy by simply submitting the array through DataHandler:
3328
        /** @var DataHandler $copyTCE */
3329
        $copyTCE = $this->getLocalTCE();
3330
        $copyTCE->start($data, [], $this->BE_USER);
3331
        $copyTCE->process_datamap();
3332
        // Getting the new UID:
3333
        $theNewSQLID = $copyTCE->substNEWwithIDs[$theNewID] ?? null;
3334
        if ($theNewSQLID) {
3335
            $this->copyMappingArray[$table][$origUid] = $theNewSQLID;
3336
            // Keep automatically versionized record information:
3337
            if (isset($copyTCE->autoVersionIdMap[$table][$theNewSQLID])) {
3338
                $this->autoVersionIdMap[$table][$theNewSQLID] = $copyTCE->autoVersionIdMap[$table][$theNewSQLID];
3339
            }
3340
        }
3341
        $this->errorLog = array_merge($this->errorLog, $copyTCE->errorLog);
3342
        unset($copyTCE);
3343
        if (!$ignoreLocalization && $language == 0) {
3344
            //repointing the new translation records to the parent record we just created
3345
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $theNewSQLID;
3346
            if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3347
                $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = 0;
3348
            }
3349
            $this->copyL10nOverlayRecords($table, $uid, $destPid, $first, $overrideValues, $excludeFields);
3350
        }
3351
3352
        return $theNewSQLID;
3353
    }
3354
3355
    /**
3356
     * Copying pages
3357
     * Main function for copying pages.
3358
     *
3359
     * @param int $uid Page UID to copy
3360
     * @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
3361
     * @internal should only be used from within DataHandler
3362
     */
3363
    public function copyPages($uid, $destPid)
3364
    {
3365
        // Initialize:
3366
        $uid = (int)$uid;
3367
        $destPid = (int)$destPid;
3368
3369
        $copyTablesAlongWithPage = $this->getAllowedTablesToCopyWhenCopyingAPage();
3370
        // Begin to copy pages if we're allowed to:
3371
        if ($this->admin || in_array('pages', $copyTablesAlongWithPage, true)) {
3372
            // Copy this page we're on. And set first-flag (this will trigger that the record is hidden if that is configured)
3373
            // This method also copies the localizations of a page
3374
            $theNewRootID = $this->copySpecificPage($uid, $destPid, $copyTablesAlongWithPage, true);
3375
            // If we're going to copy recursively
3376
            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...
3377
                // Get ALL subpages to copy (read-permissions are respected!):
3378
                $CPtable = $this->int_pageTreeInfo([], $uid, (int)$this->copyTree, $theNewRootID);
3379
                // Now copying the subpages:
3380
                foreach ($CPtable as $thePageUid => $thePagePid) {
3381
                    $newPid = $this->copyMappingArray['pages'][$thePagePid];
3382
                    if (isset($newPid)) {
3383
                        $this->copySpecificPage($thePageUid, $newPid, $copyTablesAlongWithPage);
3384
                    } else {
3385
                        $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Something went wrong during copying branch');
3386
                        break;
3387
                    }
3388
                }
3389
            }
3390
        } else {
3391
            $this->log('pages', $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to copy page without permission to this table');
3392
        }
3393
    }
3394
3395
    /**
3396
     * Compile a list of tables that should be copied along when a page is about to be copied.
3397
     *
3398
     * First, get the list that the user is allowed to modify (all if admin),
3399
     * and then check against a possible limitation within "DataHandler->copyWhichTables" if not set to "*"
3400
     * to limit the list further down
3401
     *
3402
     * @return array
3403
     */
3404
    protected function getAllowedTablesToCopyWhenCopyingAPage(): array
3405
    {
3406
        // Finding list of tables to copy.
3407
        // These are the tables, the user may modify
3408
        $copyTablesArray = $this->admin ? $this->compileAdminTables() : explode(',', $this->BE_USER->groupData['tables_modify']);
3409
        // If not all tables are allowed then make a list of allowed tables.
3410
        // That is the tables that figure in both allowed tables AND the copyTable-list
3411
        if (!str_contains($this->copyWhichTables, '*')) {
3412
            $definedTablesToCopy = GeneralUtility::trimExplode(',', $this->copyWhichTables, true);
3413
            // Pages are always allowed
3414
            $definedTablesToCopy[] = 'pages';
3415
            $definedTablesToCopy = array_flip($definedTablesToCopy);
3416
            foreach ($copyTablesArray as $k => $table) {
3417
                if (!$table || !isset($definedTablesToCopy[$table])) {
3418
                    unset($copyTablesArray[$k]);
3419
                }
3420
            }
3421
        }
3422
        $copyTablesArray = array_unique($copyTablesArray);
3423
        return $copyTablesArray;
3424
    }
3425
    /**
3426
     * Copying a single page ($uid) to $destPid and all tables in the array copyTablesArray.
3427
     *
3428
     * @param int $uid Page uid
3429
     * @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
3430
     * @param array $copyTablesArray Table on pages to copy along with the page.
3431
     * @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
3432
     * @return int|null The id of the new page, if applicable.
3433
     * @internal should only be used from within DataHandler
3434
     */
3435
    public function copySpecificPage($uid, $destPid, $copyTablesArray, $first = false)
3436
    {
3437
        // Copy the page itself:
3438
        $theNewRootID = $this->copyRecord('pages', $uid, $destPid, $first);
3439
        $currentWorkspaceId = (int)$this->BE_USER->workspace;
3440
        // If a new page was created upon the copy operation we will proceed with all the tables ON that page:
3441
        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...
3442
            foreach ($copyTablesArray as $table) {
3443
                // All records under the page is copied.
3444
                if ($table && is_array($GLOBALS['TCA'][$table]) && $table !== 'pages') {
3445
                    $fields = ['uid'];
3446
                    $languageField = null;
3447
                    $transOrigPointerField = null;
3448
                    $translationSourceField = null;
3449
                    if (BackendUtility::isTableLocalizable($table)) {
3450
                        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
3451
                        $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
3452
                        $fields[] = $languageField;
3453
                        $fields[] = $transOrigPointerField;
3454
                        if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
3455
                            $translationSourceField = $GLOBALS['TCA'][$table]['ctrl']['translationSource'];
3456
                            $fields[] = $translationSourceField;
3457
                        }
3458
                    }
3459
                    $isTableWorkspaceEnabled = BackendUtility::isTableWorkspaceEnabled($table);
3460
                    if ($isTableWorkspaceEnabled) {
3461
                        $fields[] = 't3ver_oid';
3462
                        $fields[] = 't3ver_state';
3463
                        $fields[] = 't3ver_wsid';
3464
                    }
3465
                    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
3466
                    $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
3467
                    $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $currentWorkspaceId));
3468
                    $queryBuilder
3469
                        ->select(...$fields)
3470
                        ->from($table)
3471
                        ->where(
3472
                            $queryBuilder->expr()->eq(
3473
                                'pid',
3474
                                $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
3475
                            )
3476
                        );
3477
                    if (!empty($GLOBALS['TCA'][$table]['ctrl']['sortby'])) {
3478
                        $queryBuilder->orderBy($GLOBALS['TCA'][$table]['ctrl']['sortby'], 'DESC');
3479
                    }
3480
                    $queryBuilder->addOrderBy('uid');
3481
                    try {
3482
                        $result = $queryBuilder->execute();
3483
                        $rows = [];
3484
                        $movedLiveIds = [];
3485
                        $movedLiveRecords = [];
3486
                        while ($row = $result->fetchAssociative()) {
3487
                            if ($isTableWorkspaceEnabled && (int)$row['t3ver_state'] === VersionState::MOVE_POINTER) {
3488
                                $movedLiveIds[(int)$row['t3ver_oid']] = (int)$row['uid'];
3489
                            }
3490
                            $rows[(int)$row['uid']] = $row;
3491
                        }
3492
                        // Resolve placeholders of workspace versions
3493
                        if (!empty($rows) && $currentWorkspaceId > 0 && $isTableWorkspaceEnabled) {
3494
                            // If a record was moved within the page, the PlainDataResolver needs the moved record
3495
                            // but not the original live version, otherwise the moved record is not considered at all.
3496
                            // For this reason, we find the live ids, where there was also a moved record in the SQL
3497
                            // query above in $movedLiveIds and now we removed them before handing them over to PlainDataResolver.
3498
                            // see changeContentSortingAndCopyDraftPage test
3499
                            foreach ($movedLiveIds as $liveId => $movePlaceHolderId) {
3500
                                if (isset($rows[$liveId])) {
3501
                                    $movedLiveRecords[$movePlaceHolderId] = $rows[$liveId];
3502
                                    unset($rows[$liveId]);
3503
                                }
3504
                            }
3505
                            $rows = array_reverse(
3506
                                $this->resolveVersionedRecords(
3507
                                    $table,
3508
                                    implode(',', $fields),
3509
                                    $GLOBALS['TCA'][$table]['ctrl']['sortby'],
3510
                                    array_keys($rows)
3511
                                ),
3512
                                true
3513
                            );
3514
                            foreach ($movedLiveRecords as $movePlaceHolderId => $liveRecord) {
3515
                                $rows[$movePlaceHolderId] = $liveRecord;
3516
                            }
3517
                        }
3518
                        if (is_array($rows)) {
3519
                            $languageSourceMap = [];
3520
                            $overrideValues = $translationSourceField ? [$translationSourceField => 0] : [];
3521
                            $doRemap = false;
3522
                            foreach ($rows as $row) {
3523
                                // Skip localized records that will be processed in
3524
                                // copyL10nOverlayRecords() on copying the default language record
3525
                                $transOrigPointer = $row[$transOrigPointerField] ?? 0;
3526
                                if (!empty($languageField)
3527
                                    && $row[$languageField] > 0
3528
                                    && $transOrigPointer > 0
3529
                                    && (isset($rows[$transOrigPointer]) || isset($movedLiveIds[$transOrigPointer]))
3530
                                ) {
3531
                                    continue;
3532
                                }
3533
                                // Copying each of the underlying records...
3534
                                $newUid = $this->copyRecord($table, $row['uid'], $theNewRootID, false, $overrideValues);
3535
                                if ($translationSourceField) {
3536
                                    $languageSourceMap[$row['uid']] = $newUid;
3537
                                    if ($row[$languageField] > 0) {
3538
                                        $doRemap = true;
3539
                                    }
3540
                                }
3541
                            }
3542
                            if ($doRemap) {
3543
                                //remap is needed for records in non-default language records in the "free mode"
3544
                                $this->copy_remapTranslationSourceField($table, $rows, $languageSourceMap);
3545
                            }
3546
                        }
3547
                    } catch (DBALException $e) {
3548
                        $databaseErrorMessage = $e->getPrevious()->getMessage();
3549
                        $this->log($table, $uid, SystemLogDatabaseAction::CHECK, 0, SystemLogErrorClassification::USER_ERROR, 'An SQL error occurred: ' . $databaseErrorMessage);
3550
                    }
3551
                }
3552
            }
3553
            $this->processRemapStack();
3554
            return $theNewRootID;
3555
        }
3556
        return null;
3557
    }
3558
3559
    /**
3560
     * Copying records, but makes a "raw" copy of a record.
3561
     * Basically the only thing observed is field processing like the copying of files and correction of ids. All other fields are 1-1 copied.
3562
     * 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.
3563
     * 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!?
3564
     * This function is used to create new versions of a record.
3565
     * 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.
3566
     *
3567
     * @param string $table Element table
3568
     * @param int $uid Element UID
3569
     * @param int $pid Element PID (real PID, not checked)
3570
     * @param array $overrideArray Override array - must NOT contain any fields not in the table!
3571
     * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently
3572
     * @return int Returns the new ID of the record (if applicable)
3573
     * @internal should only be used from within DataHandler
3574
     */
3575
    public function copyRecord_raw($table, $uid, $pid, $overrideArray = [], array $workspaceOptions = [])
3576
    {
3577
        $uid = (int)$uid;
3578
        // Stop any actions if the record is marked to be deleted:
3579
        // (this can occur if IRRE elements are versionized and child elements are removed)
3580
        if ($this->isElementToBeDeleted($table, $uid)) {
3581
            return null;
3582
        }
3583
        // Only copy if the table is defined in TCA, a uid is given and the record wasn't copied before:
3584
        if (!$GLOBALS['TCA'][$table] || !$uid || $this->isRecordCopied($table, $uid)) {
3585
            return null;
3586
        }
3587
3588
        // Fetch record with permission check
3589
        $row = $this->recordInfoWithPermissionCheck($table, $uid, Permission::PAGE_SHOW);
3590
3591
        // This checks if the record can be selected which is all that a copy action requires.
3592
        if ($row === false) {
3593
            $this->log(
3594
                $table,
3595
                $uid,
3596
                SystemLogDatabaseAction::DELETE,
3597
                0,
3598
                SystemLogErrorClassification::USER_ERROR,
3599
                'Attempt to rawcopy/versionize record which either does not exist or you don\'t have permission to read'
3600
            );
3601
            return null;
3602
        }
3603
3604
        // Set up fields which should not be processed. They are still written - just passed through no-questions-asked!
3605
        $nonFields = ['uid', 'pid', 't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody'];
3606
3607
        // Merge in override array.
3608
        $row = array_merge($row, $overrideArray);
3609
        // Traverse ALL fields of the selected record:
3610
        foreach ($row as $field => $value) {
3611
            /** @var string $field */
3612
            if (!in_array($field, $nonFields, true)) {
3613
                // Get TCA configuration for the field:
3614
                $conf = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? false;
3615
                if (is_array($conf)) {
3616
                    // Processing based on the TCA config field type (files, references, flexforms...)
3617
                    $value = $this->copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $pid, 0, $workspaceOptions);
3618
                }
3619
                // Add value to array.
3620
                $row[$field] = $value;
3621
            }
3622
        }
3623
        $row['pid'] = $pid;
3624
        // Setting original UID:
3625
        if ($GLOBALS['TCA'][$table]['ctrl']['origUid'] ?? '') {
3626
            $row[$GLOBALS['TCA'][$table]['ctrl']['origUid']] = $uid;
3627
        }
3628
        // Do the copy by internal function
3629
        $theNewSQLID = $this->insertNewCopyVersion($table, $row, $pid);
3630
3631
        // When a record is copied in workspace (eg. to create a delete placeholder record for a live record), records
3632
        // pointing to that record need a reference index update. This is for instance the case in FAL, if a sys_file_reference
3633
        // for a eg. tt_content record is marked as deleted. The tt_content record then needs a reference index update.
3634
        // This scenario seems to currently only show up if in workspaces, so the refindex update is restricted to this for now.
3635
        if (!empty($workspaceOptions)) {
3636
            $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$row['uid'], (int)$this->BE_USER->workspace);
3637
        }
3638
3639
        if ($theNewSQLID) {
3640
            $this->dbAnalysisStoreExec();
3641
            $this->dbAnalysisStore = [];
3642
            return $this->copyMappingArray[$table][$uid] = $theNewSQLID;
3643
        }
3644
        return null;
3645
    }
3646
3647
    /**
3648
     * Inserts a record in the database, passing TCA configuration values through checkValue() but otherwise does NOTHING and checks nothing regarding permissions.
3649
     * 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...
3650
     *
3651
     * @param string $table Table name
3652
     * @param array $fieldArray Field array to insert as a record
3653
     * @param int $realPid The value of PID field.
3654
     * @return int Returns the new ID of the record (if applicable)
3655
     * @internal should only be used from within DataHandler
3656
     */
3657
    public function insertNewCopyVersion($table, $fieldArray, $realPid)
3658
    {
3659
        $id = StringUtility::getUniqueId('NEW');
3660
        // $fieldArray is set as current record.
3661
        // 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...
3662
        $this->checkValue_currentRecord = $fieldArray;
3663
        // Makes sure that transformations aren't processed on the copy.
3664
        $backupDontProcessTransformations = $this->dontProcessTransformations;
3665
        $this->dontProcessTransformations = true;
3666
        // Traverse record and input-process each value:
3667
        foreach ($fieldArray as $field => $fieldValue) {
3668
            if (isset($GLOBALS['TCA'][$table]['columns'][$field])) {
3669
                // Evaluating the value.
3670
                $res = $this->checkValue($table, $field, $fieldValue, $id, 'new', $realPid, 0, $fieldArray);
3671
                if (isset($res['value'])) {
3672
                    $fieldArray[$field] = $res['value'];
3673
                }
3674
            }
3675
        }
3676
        // System fields being set:
3677
        if ($GLOBALS['TCA'][$table]['ctrl']['crdate']) {
3678
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
3679
        }
3680
        if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id']) {
3681
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
3682
        }
3683
        if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
3684
            $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
3685
        }
3686
        // Finally, insert record:
3687
        $this->insertDB($table, $id, $fieldArray, true);
3688
        // Resets dontProcessTransformations to the previous state.
3689
        $this->dontProcessTransformations = $backupDontProcessTransformations;
3690
        // Return new id:
3691
        return $this->substNEWwithIDs[$id];
3692
    }
3693
3694
    /**
3695
     * Processing/Preparing content for copyRecord() function
3696
     *
3697
     * @param string $table Table name
3698
     * @param int $uid Record uid
3699
     * @param string $field Field name being processed
3700
     * @param string $value Input value to be processed.
3701
     * @param array $row Record array
3702
     * @param array $conf TCA field configuration
3703
     * @param int $realDestPid Real page id (pid) the record is copied to
3704
     * @param int $language Language ID (from sys_language table) used in the duplicated record
3705
     * @param array $workspaceOptions Options to be forwarded if actions happen on a workspace currently
3706
     * @return array|string
3707
     * @internal
3708
     * @see copyRecord()
3709
     */
3710
    public function copyRecord_procBasedOnFieldType($table, $uid, $field, $value, $row, $conf, $realDestPid, $language = 0, array $workspaceOptions = [])
3711
    {
3712
        $inlineSubType = $this->getInlineFieldType($conf);
3713
        // Get the localization mode for the current (parent) record (keep|select):
3714
        // Register if there are references to take care of or MM is used on an inline field (no change to value):
3715
        if ($this->isReferenceField($conf) || $inlineSubType === 'mm') {
3716
            $value = $this->copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language);
3717
        } elseif ($inlineSubType !== false) {
3718
            $value = $this->copyRecord_processInline($table, $uid, $field, $value, $row, $conf, $realDestPid, $language, $workspaceOptions);
3719
        }
3720
        // 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())
3721
        if (isset($conf['type']) && $conf['type'] === 'flex') {
3722
            // Get current value array:
3723
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
3724
            $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
3725
                ['config' => $conf],
3726
                $table,
3727
                $field,
3728
                $row
3729
            );
3730
            $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
3731
            $currentValueArray = GeneralUtility::xml2array($value);
3732
            // Traversing the XML structure, processing files:
3733
            if (is_array($currentValueArray)) {
3734
                $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], $dataStructureArray, [$table, $uid, $field, $realDestPid], 'copyRecord_flexFormCallBack', $workspaceOptions);
3735
                // Setting value as an array! -> which means the input will be processed according to the 'flex' type when the new copy is created.
3736
                $value = $currentValueArray;
3737
            }
3738
        }
3739
        return $value;
3740
    }
3741
3742
    /**
3743
     * Processes the children of an MM relation field (select, group, inline) when the parent record is copied.
3744
     *
3745
     * @param string $table
3746
     * @param int $uid
3747
     * @param string $field
3748
     * @param string $value
3749
     * @param array $conf
3750
     * @param int $language
3751
     * @return string
3752
     */
3753
    protected function copyRecord_processManyToMany($table, $uid, $field, $value, $conf, $language)
3754
    {
3755
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
3756
        $allowedTablesArray = GeneralUtility::trimExplode(',', $allowedTables, true);
3757
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
3758
        $mmTable = !empty($conf['MM']) ? $conf['MM'] : '';
3759
3760
        $dbAnalysis = $this->createRelationHandlerInstance();
3761
        $dbAnalysis->start($value, $allowedTables, $mmTable, $uid, $table, $conf);
3762
        $purgeItems = false;
3763
3764
        // Check if referenced records of select or group fields should also be localized in general.
3765
        // A further check is done in the loop below for each table name.
3766
        if ($language > 0 && $mmTable === '' && !empty($conf['localizeReferencesAtParentLocalization'])) {
3767
            // Check whether allowed tables can be localized.
3768
            $localizeTables = [];
3769
            foreach ($allowedTablesArray as $allowedTable) {
3770
                $localizeTables[$allowedTable] = BackendUtility::isTableLocalizable($allowedTable);
3771
            }
3772
3773
            foreach ($dbAnalysis->itemArray as $index => $item) {
3774
                // No action required, if referenced tables cannot be localized (current value will be used).
3775
                if (empty($localizeTables[$item['table']])) {
3776
                    continue;
3777
                }
3778
3779
                // Since select or group fields can reference many records, check whether there's already a localization.
3780
                $recordLocalization = BackendUtility::getRecordLocalization($item['table'], $item['id'], $language);
3781
                if ($recordLocalization) {
3782
                    $dbAnalysis->itemArray[$index]['id'] = $recordLocalization[0]['uid'];
3783
                } elseif ($this->isNestedElementCallRegistered($item['table'], $item['id'], 'localize-' . $language) === false) {
3784
                    $dbAnalysis->itemArray[$index]['id'] = $this->localize($item['table'], $item['id'], $language);
3785
                }
3786
            }
3787
            $purgeItems = true;
3788
        }
3789
3790
        if ($purgeItems || $mmTable !== '') {
3791
            $dbAnalysis->purgeItemArray();
3792
            $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

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

4403
                $queryBuilder->setParameter('pointer', abs(/** @scrutinizer ignore-type */ $originalRecordDestinationPid), \PDO::PARAM_INT);
Loading history...
4404
                $destL10nRecords = $queryBuilder->execute()->fetchAllAssociative();
4405
                // Index the localized record uids by language
4406
                if (is_array($destL10nRecords)) {
4407
                    foreach ($destL10nRecords as $record) {
4408
                        $localizedDestPids[$record[$languageField]] = -$record['uid'];
4409
                    }
4410
                }
4411
            }
4412
            // Move the localized records after the corresponding localizations of the destination record
4413
            foreach ($l10nRecords as $record) {
4414
                $localizedDestPid = (int)($localizedDestPids[$record[$languageField]] ?? 0);
4415
                if ($localizedDestPid < 0) {
4416
                    $this->moveRecord($table, $record['uid'], $localizedDestPid);
4417
                } else {
4418
                    $this->moveRecord($table, $record['uid'], $destPid);
4419
                }
4420
            }
4421
        }
4422
    }
4423
4424
    /**
4425
     * Localizes a record to another system language
4426
     *
4427
     * @param string $table Table name
4428
     * @param int $uid Record uid (to be localized)
4429
     * @param int $language Language ID (from sys_language table)
4430
     * @return int|bool The uid (int) of the new translated record or FALSE (bool) if something went wrong
4431
     * @internal should only be used from within DataHandler
4432
     */
4433
    public function localize($table, $uid, $language)
4434
    {
4435
        $newId = false;
4436
        $uid = (int)$uid;
4437
        if (!$GLOBALS['TCA'][$table] || !$uid || $this->isNestedElementCallRegistered($table, $uid, 'localize-' . (string)$language) !== false) {
4438
            return false;
4439
        }
4440
4441
        $this->registerNestedElementCall($table, $uid, 'localize-' . (string)$language);
4442
        if (empty($GLOBALS['TCA'][$table]['ctrl']['languageField']) || empty($GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'])) {
4443
            $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Localization failed; "languageField" and "transOrigPointerField" must be defined for the table ' . $table);
4444
            return false;
4445
        }
4446
4447
        if (!$this->doesRecordExist($table, $uid, Permission::PAGE_SHOW)) {
4448
            $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record ' . $table . ':' . $uid . ' without permission.');
4449
            return false;
4450
        }
4451
4452
        // Getting workspace overlay if possible - this will localize versions in workspace if any
4453
        $row = BackendUtility::getRecordWSOL($table, $uid);
4454
        if (!is_array($row)) {
0 ignored issues
show
introduced by
The condition is_array($row) is always true.
Loading history...
4455
            $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to localize record ' . $table . ':' . $uid . ' that did not exist!');
4456
            return false;
4457
        }
4458
4459
        [$pageId] = BackendUtility::getTSCpid($table, $uid, '');
4460
        // Try to fetch the site language from the pages' associated site
4461
        $siteLanguage = $this->getSiteLanguageForPage((int)$pageId, (int)$language);
4462
        if ($siteLanguage === null) {
4463
            $this->log($table, $uid, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Sys language UID "' . $language . '" not found valid!');
4464
            return false;
4465
        }
4466
4467
        // Make sure that records which are translated from another language than the default language have a correct
4468
        // localization source set themselves, before translating them to another language.
4469
        if ((int)$row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4470
            && $row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0) {
4471
            $localizationParentRecord = BackendUtility::getRecord(
4472
                $table,
4473
                $row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']]
4474
            );
4475
            if ((int)$localizationParentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']] !== 0) {
4476
                $this->log($table, $localizationParentRecord['uid'], SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Localization failed; Source record ' . $table . ':' . $localizationParentRecord['uid'] . ' contained a reference to an original record that is not a default record (which is strange)!');
4477
                return false;
4478
            }
4479
        }
4480
4481
        // Default language records must never have a localization parent as they are the origin of any translation.
4482
        if ((int)$row[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] !== 0
4483
            && (int)$row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4484
            $this->log($table, $row['uid'], SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Localization failed; Source record ' . $table . ':' . $row['uid'] . ' contained a reference to an original default record but is a default record itself (which is strange)!');
4485
            return false;
4486
        }
4487
4488
        $recordLocalizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'AND pid=' . (int)$row['pid']);
4489
4490
        if (!empty($recordLocalizations)) {
4491
            $this->log(
4492
                $table,
4493
                $uid,
4494
                SystemLogDatabaseAction::LOCALIZE,
4495
                0,
4496
                SystemLogErrorClassification::USER_ERROR,
4497
                'Localization failed: there already are localizations (%s) for language %d of the "%s" record %d!',
4498
                -1,
4499
                [
4500
                    implode(', ', array_column($recordLocalizations, 'uid')),
4501
                    $language,
4502
                    $table,
4503
                    $uid,
4504
                ]
4505
            );
4506
            return false;
4507
        }
4508
4509
        // Initialize:
4510
        $overrideValues = [];
4511
        // Set override values:
4512
        $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['languageField']] = (int)$language;
4513
        // If the translated record is a default language record, set it's uid as localization parent of the new record.
4514
        // If translating from any other language, no override is needed; we just can copy the localization parent of
4515
        // the original record (which is pointing to the correspondent default language record) to the new record.
4516
        // In copy / free mode the TransOrigPointer field is always set to 0, as no connection to the localization parent is wanted in that case.
4517
        // For pages, there is no "copy/free mode".
4518
        if (($this->useTransOrigPointerField || $table === 'pages') && (int)$row[$GLOBALS['TCA'][$table]['ctrl']['languageField']] === 0) {
4519
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = $uid;
4520
        } elseif (!$this->useTransOrigPointerField) {
4521
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] = 0;
4522
        }
4523
        if (isset($GLOBALS['TCA'][$table]['ctrl']['translationSource'])) {
4524
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['translationSource']] = $uid;
4525
        }
4526
        // Copy the type (if defined in both tables) from the original record so that translation has same type as original record
4527
        if (isset($GLOBALS['TCA'][$table]['ctrl']['type'])) {
4528
            // @todo: Possible bug here? type can be something like 'table:field', which is then null in $row, writing null to $overrideValues
4529
            $overrideValues[$GLOBALS['TCA'][$table]['ctrl']['type']] = $row[$GLOBALS['TCA'][$table]['ctrl']['type']] ?? null;
4530
        }
4531
        // Set exclude Fields:
4532
        foreach ($GLOBALS['TCA'][$table]['columns'] as $fN => $fCfg) {
4533
            $translateToMsg = '';
4534
            // Check if we are just prefixing:
4535
            if (isset($fCfg['l10n_mode']) && $fCfg['l10n_mode'] === 'prefixLangTitle') {
4536
                if (($fCfg['config']['type'] === 'text' || $fCfg['config']['type'] === 'input') && (string)$row[$fN] !== '') {
4537
                    $TSConfig = BackendUtility::getPagesTSconfig($pageId)['TCEMAIN.'] ?? [];
4538
                    $tableEntries = $this->getTableEntries($table, $TSConfig);
4539
                    if (!empty($TSConfig['translateToMessage']) && !($tableEntries['disablePrependAtCopy'] ?? false)) {
4540
                        $translateToMsg = $this->getLanguageService()->sL($TSConfig['translateToMessage']);
4541
                        $translateToMsg = @sprintf($translateToMsg, $siteLanguage->getTitle());
4542
                    }
4543
4544
                    foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processTranslateToClass'] ?? [] as $className) {
4545
                        $hookObj = GeneralUtility::makeInstance($className);
4546
                        if (method_exists($hookObj, 'processTranslateTo_copyAction')) {
4547
                            // @todo Deprecate passing an array and pass the full SiteLanguage object instead
4548
                            $hookObj->processTranslateTo_copyAction(
4549
                                $row[$fN],
4550
                                ['uid' => $siteLanguage->getLanguageId(), 'title' => $siteLanguage->getTitle()],
4551
                                $this,
4552
                                $fN
4553
                            );
4554
                        }
4555
                    }
4556
                    if (!empty($translateToMsg)) {
4557
                        $overrideValues[$fN] = '[' . $translateToMsg . '] ' . $row[$fN];
4558
                    } else {
4559
                        $overrideValues[$fN] = $row[$fN];
4560
                    }
4561
                }
4562
            }
4563
            if (($fCfg['config']['MM'] ?? false) && !empty($fCfg['config']['MM_oppositeUsage'])) {
4564
                // We are localizing the 'local' side of an MM relation. (eg. localizing a category).
4565
                // In this case, MM relations connected to the default lang record should not be copied,
4566
                // so we set an override here to not trigger mm handling of 'items' field for this.
4567
                $overrideValues[$fN] = 0;
4568
            }
4569
        }
4570
4571
        if ($table !== 'pages') {
4572
            // Get the uid of record after which this localized record should be inserted
4573
            $previousUid = $this->getPreviousLocalizedRecordUid($table, $uid, $row['pid'], $language);
4574
            // Execute the copy:
4575
            $newId = $this->copyRecord($table, $uid, -$previousUid, true, $overrideValues, '', $language);
4576
        } else {
4577
            // Create new page which needs to contain the same pid as the original page
4578
            $overrideValues['pid'] = $row['pid'];
4579
            // Take over the hidden state of the original language state, this is done due to legacy reasons where-as
4580
            // pages_language_overlay was set to "hidden -> default=0" but pages hidden -> default 1"
4581
            if (!empty($GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'])) {
4582
                $hiddenFieldName = $GLOBALS['TCA'][$table]['ctrl']['enablecolumns']['disabled'];
4583
                $overrideValues[$hiddenFieldName] = $row[$hiddenFieldName] ?? $GLOBALS['TCA'][$table]['columns'][$hiddenFieldName]['config']['default'];
4584
                // Override by TCA "hideAtCopy" or pageTS "disableHideAtCopy"
4585
                // Only for visible pages to get the same behaviour as for copy
4586
                if (!$overrideValues[$hiddenFieldName]) {
4587
                    $TSConfig = BackendUtility::getPagesTSconfig($uid)['TCEMAIN.'] ?? [];
4588
                    $tableEntries = $this->getTableEntries($table, $TSConfig);
4589
                    if (
4590
                        ($GLOBALS['TCA'][$table]['ctrl']['hideAtCopy'] ?? false)
4591
                        && !$this->neverHideAtCopy
4592
                        && !($tableEntries['disableHideAtCopy'] ?? false)
4593
                    ) {
4594
                        $overrideValues[$hiddenFieldName] = 1;
4595
                    }
4596
                }
4597
            }
4598
            $temporaryId = StringUtility::getUniqueId('NEW');
4599
            $copyTCE = $this->getLocalTCE();
4600
            $copyTCE->start([$table => [$temporaryId => $overrideValues]], [], $this->BE_USER);
4601
            $copyTCE->process_datamap();
4602
            // Getting the new UID as if it had been copied:
4603
            $theNewSQLID = $copyTCE->substNEWwithIDs[$temporaryId];
4604
            if ($theNewSQLID) {
4605
                $this->copyMappingArray[$table][$uid] = $theNewSQLID;
4606
                $newId = $theNewSQLID;
4607
            }
4608
        }
4609
4610
        return $newId;
4611
    }
4612
4613
    /**
4614
     * Performs localization or synchronization of child records.
4615
     * The $command argument expects an array, but supports a string for backward-compatibility.
4616
     *
4617
     * $command = array(
4618
     *   'field' => 'tx_myfieldname',
4619
     *   'language' => 2,
4620
     *   // either the key 'action' or 'ids' must be set
4621
     *   'action' => 'synchronize', // or 'localize'
4622
     *   'ids' => array(1, 2, 3, 4) // child element ids
4623
     * );
4624
     *
4625
     * @param string $table The table of the localized parent record
4626
     * @param int $id The uid of the localized parent record
4627
     * @param array|string $command Defines the command to be performed (see example above)
4628
     */
4629
    protected function inlineLocalizeSynchronize($table, $id, $command)
4630
    {
4631
        $parentRecord = BackendUtility::getRecordWSOL($table, $id);
4632
4633
        // Backward-compatibility handling
4634
        if (!is_array($command)) {
4635
            // @deprecated, will be removed in TYPO3 v12.0.
4636
            trigger_error('DataHandler command InlineLocalizeSynchronize needs to use an array as command input, which is available since TYPO3 v7.6. This fallback mechanism will be removed in TYPO3 v12.0.', E_USER_DEPRECATED);
4637
            // <field>, (localize | synchronize | <uid>):
4638
            $parts = GeneralUtility::trimExplode(',', $command);
4639
            $command = [
4640
                'field' => $parts[0],
4641
                // The previous process expected $id to point to the localized record already
4642
                'language' => (int)$parentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']],
4643
            ];
4644
            if (!MathUtility::canBeInterpretedAsInteger($parts[1])) {
4645
                $command['action'] = $parts[1];
4646
            } else {
4647
                $command['ids'] = [$parts[1]];
4648
            }
4649
        }
4650
4651
        // In case the parent record is the default language record, fetch the localization
4652
        if (empty($parentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']])) {
4653
            // Fetch the live record
4654
            // @todo: this needs to be revisited, as getRecordLocalization() does a BackendWorkspaceRestriction
4655
            // based on $GLOBALS[BE_USER], which could differ from the $this->BE_USER->workspace value
4656
            $parentRecordLocalization = BackendUtility::getRecordLocalization($table, $id, $command['language'], 'AND t3ver_oid=0');
4657
            if (empty($parentRecordLocalization)) {
4658
                $this->log($table, $id, SystemLogDatabaseAction::LOCALIZE, 0, SystemLogErrorClassification::MESSAGE, 'Localization for parent record ' . $table . ':' . $id . '" cannot be fetched', -1, [], $this->eventPid($table, $id, $parentRecord['pid']));
4659
                return;
4660
            }
4661
            $parentRecord = $parentRecordLocalization[0];
4662
            $id = $parentRecord['uid'];
4663
            // Process overlay for current selected workspace
4664
            BackendUtility::workspaceOL($table, $parentRecord);
4665
        }
4666
4667
        $field = $command['field'];
4668
        $language = $command['language'];
4669
        $action = $command['action'];
4670
        $ids = $command['ids'] ?? [];
4671
4672
        if (!$field || !($action === 'localize' || $action === 'synchronize') && empty($ids) || !isset($GLOBALS['TCA'][$table]['columns'][$field]['config'])) {
4673
            return;
4674
        }
4675
4676
        $config = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
4677
        $foreignTable = $config['foreign_table'];
4678
4679
        $transOrigPointer = (int)$parentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']];
4680
        $childTransOrigPointerField = $GLOBALS['TCA'][$foreignTable]['ctrl']['transOrigPointerField'];
4681
4682
        if (!$parentRecord || !is_array($parentRecord) || $language <= 0 || !$transOrigPointer) {
4683
            return;
4684
        }
4685
4686
        $inlineSubType = $this->getInlineFieldType($config);
4687
        if ($inlineSubType === false) {
4688
            return;
4689
        }
4690
4691
        $transOrigRecord = BackendUtility::getRecordWSOL($table, $transOrigPointer);
4692
4693
        $removeArray = [];
4694
        $mmTable = $inlineSubType === 'mm' && isset($config['MM']) && $config['MM'] ? $config['MM'] : '';
4695
        // Fetch children from original language parent:
4696
        /** @var RelationHandler $dbAnalysisOriginal */
4697
        $dbAnalysisOriginal = $this->createRelationHandlerInstance();
4698
        $dbAnalysisOriginal->start($transOrigRecord[$field], $foreignTable, $mmTable, $transOrigRecord['uid'], $table, $config);
4699
        $elementsOriginal = [];
4700
        foreach ($dbAnalysisOriginal->itemArray as $item) {
4701
            $elementsOriginal[$item['id']] = $item;
4702
        }
4703
        unset($dbAnalysisOriginal);
4704
        // Fetch children from current localized parent:
4705
        /** @var RelationHandler $dbAnalysisCurrent */
4706
        $dbAnalysisCurrent = $this->createRelationHandlerInstance();
4707
        $dbAnalysisCurrent->start($parentRecord[$field], $foreignTable, $mmTable, $id, $table, $config);
4708
        // Perform synchronization: Possibly removal of already localized records:
4709
        if ($action === 'synchronize') {
4710
            foreach ($dbAnalysisCurrent->itemArray as $index => $item) {
4711
                $childRecord = BackendUtility::getRecordWSOL($item['table'], $item['id']);
4712
                if (isset($childRecord[$childTransOrigPointerField]) && $childRecord[$childTransOrigPointerField] > 0) {
4713
                    $childTransOrigPointer = $childRecord[$childTransOrigPointerField];
4714
                    // If synchronization is requested, child record was translated once, but original record does not exist anymore, remove it:
4715
                    if (!isset($elementsOriginal[$childTransOrigPointer])) {
4716
                        unset($dbAnalysisCurrent->itemArray[$index]);
4717
                        $removeArray[$item['table']][$item['id']]['delete'] = 1;
4718
                    }
4719
                }
4720
            }
4721
        }
4722
        // Perform synchronization/localization: Possibly add unlocalized records for original language:
4723
        if ($action === 'localize' || $action === 'synchronize') {
4724
            foreach ($elementsOriginal as $originalId => $item) {
4725
                if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4726
                    continue;
4727
                }
4728
                $item['id'] = $this->localize($item['table'], $item['id'], $language);
4729
4730
                if (is_int($item['id'])) {
4731
                    $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4732
                }
4733
                $dbAnalysisCurrent->itemArray[] = $item;
4734
            }
4735
        } elseif (!empty($ids)) {
4736
            foreach ($ids as $childId) {
4737
                if (!MathUtility::canBeInterpretedAsInteger($childId) || !isset($elementsOriginal[$childId])) {
4738
                    continue;
4739
                }
4740
                $item = $elementsOriginal[$childId];
4741
                if ($this->isRecordLocalized((string)$item['table'], (int)$item['id'], (int)$language)) {
4742
                    continue;
4743
                }
4744
                $item['id'] = $this->localize($item['table'], $item['id'], $language);
4745
                if (is_int($item['id'])) {
4746
                    $item['id'] = $this->overlayAutoVersionId($item['table'], $item['id']);
4747
                }
4748
                $dbAnalysisCurrent->itemArray[] = $item;
4749
            }
4750
        }
4751
        // Store the new values, we will set up the uids for the subtype later on (exception keep localization from original record):
4752
        $value = implode(',', $dbAnalysisCurrent->getValueArray());
4753
        $this->registerDBList[$table][$id][$field] = $value;
4754
        // Remove child records (if synchronization requested it):
4755
        if (is_array($removeArray) && !empty($removeArray)) {
4756
            /** @var DataHandler $tce */
4757
            $tce = GeneralUtility::makeInstance(__CLASS__, $this->referenceIndexUpdater);
4758
            $tce->enableLogging = $this->enableLogging;
4759
            $tce->start([], $removeArray, $this->BE_USER);
4760
            $tce->process_cmdmap();
4761
            unset($tce);
4762
        }
4763
        $updateFields = [];
4764
        // Handle, reorder and store relations:
4765
        if ($inlineSubType === 'list') {
4766
            $updateFields = [$field => $value];
4767
        } elseif ($inlineSubType === 'field') {
4768
            $dbAnalysisCurrent->writeForeignField($config, $id);
0 ignored issues
show
Deprecated Code introduced by
The function TYPO3\CMS\Core\Database\...er::writeForeignField() has been deprecated. ( Ignorable by Annotation )

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

4768
            /** @scrutinizer ignore-deprecated */ $dbAnalysisCurrent->writeForeignField($config, $id);
Loading history...
4769
            $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4770
        } elseif ($inlineSubType === 'mm') {
4771
            $dbAnalysisCurrent->writeMM($config['MM'], $id);
4772
            $updateFields = [$field => $dbAnalysisCurrent->countItems(false)];
4773
        }
4774
        // Update field referencing to child records of localized parent record:
4775
        if (!empty($updateFields)) {
4776
            $this->updateDB($table, $id, $updateFields);
4777
        }
4778
    }
4779
4780
    /**
4781
     * Returns true if a localization of a record exists.
4782
     *
4783
     * @param string $table
4784
     * @param int $uid
4785
     * @param int $language
4786
     * @return bool
4787
     */
4788
    protected function isRecordLocalized(string $table, int $uid, int $language): bool
4789
    {
4790
        $row = BackendUtility::getRecordWSOL($table, $uid);
4791
        $localizations = BackendUtility::getRecordLocalization($table, $uid, $language, 'pid=' . (int)$row['pid']);
4792
        return !empty($localizations);
4793
    }
4794
4795
    /*********************************************
4796
     *
4797
     * Cmd: delete
4798
     *
4799
     ********************************************/
4800
    /**
4801
     * Delete a single record
4802
     *
4803
     * @param string $table Table name
4804
     * @param int $id Record UID
4805
     * @internal should only be used from within DataHandler
4806
     */
4807
    public function deleteAction($table, $id)
4808
    {
4809
        $recordToDelete = BackendUtility::getRecord($table, $id);
4810
4811
        if (is_array($recordToDelete) && isset($recordToDelete['t3ver_wsid']) && (int)$recordToDelete['t3ver_wsid'] !== 0) {
4812
            // When dealing with a workspace record, use discard.
4813
            $this->discard($table, null, $recordToDelete);
4814
            return;
4815
        }
4816
4817
        // Record asked to be deleted was found:
4818
        if (is_array($recordToDelete)) {
4819
            $recordWasDeleted = false;
4820
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
4821
                $hookObj = GeneralUtility::makeInstance($className);
4822
                if (method_exists($hookObj, 'processCmdmap_deleteAction')) {
4823
                    $hookObj->processCmdmap_deleteAction($table, $id, $recordToDelete, $recordWasDeleted, $this);
4824
                }
4825
            }
4826
            // Delete the record if a hook hasn't deleted it yet
4827
            if (!$recordWasDeleted) {
0 ignored issues
show
introduced by
The condition $recordWasDeleted is always false.
Loading history...
4828
                $this->deleteEl($table, $id);
4829
            }
4830
        }
4831
    }
4832
4833
    /**
4834
     * Delete element from any table
4835
     *
4836
     * @param string $table Table name
4837
     * @param int $uid Record UID
4838
     * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record
4839
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4840
     * @param bool $deleteRecordsOnPage If false and if deleting pages, records on the page will not be deleted (edge case while swapping workspaces)
4841
     * @internal should only be used from within DataHandler
4842
     */
4843
    public function deleteEl($table, $uid, $noRecordCheck = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
4844
    {
4845
        if ($table === 'pages') {
4846
            $this->deletePages($uid, $noRecordCheck, $forceHardDelete, $deleteRecordsOnPage);
4847
        } else {
4848
            $this->discardWorkspaceVersionsOfRecord($table, $uid);
4849
            $this->deleteRecord($table, $uid, $noRecordCheck, $forceHardDelete);
4850
        }
4851
    }
4852
4853
    /**
4854
     * Discard workspace overlays of a live record: When a live row
4855
     * is deleted, all existing workspace overlays are discarded.
4856
     *
4857
     * @param string $table Table name
4858
     * @param int $uid Record UID
4859
     * @internal should only be used from within DataHandler
4860
     */
4861
    protected function discardWorkspaceVersionsOfRecord($table, $uid): void
4862
    {
4863
        $versions = BackendUtility::selectVersionsOfRecord($table, $uid, '*', null);
4864
        if ($versions === null) {
4865
            // Null is returned by selectVersionsOfRecord() when table is not workspace aware.
4866
            return;
4867
        }
4868
        foreach ($versions as $record) {
4869
            if ($record['_CURRENT_VERSION'] ?? false) {
4870
                // The live record is included in the result from selectVersionsOfRecord()
4871
                // and marked as '_CURRENT_VERSION'. Skip this one.
4872
                continue;
4873
            }
4874
            // BE user must be put into this workspace temporarily so stuff like refindex updating
4875
            // is properly registered for this workspace when discarding records in there.
4876
            $currentUserWorkspace = $this->BE_USER->workspace;
4877
            $this->BE_USER->workspace = (int)$record['t3ver_wsid'];
4878
            $this->discard($table, null, $record);
4879
            // Switch user back to original workspace
4880
            $this->BE_USER->workspace = $currentUserWorkspace;
4881
        }
4882
    }
4883
4884
    /**
4885
     * Deleting a record
4886
     * This function may not be used to delete pages-records unless the underlying records are already deleted
4887
     * Deletes a record regardless of versioning state (live or offline, doesn't matter, the uid decides)
4888
     * If both $noRecordCheck and $forceHardDelete are set it could even delete a "deleted"-flagged record!
4889
     *
4890
     * @param string $table Table name
4891
     * @param int $uid Record UID
4892
     * @param bool $noRecordCheck Flag: If $noRecordCheck is set, then the function does not check permission to delete record
4893
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
4894
     * @internal should only be used from within DataHandler
4895
     */
4896
    public function deleteRecord($table, $uid, $noRecordCheck = false, $forceHardDelete = false)
4897
    {
4898
        $currentUserWorkspace = (int)$this->BE_USER->workspace;
4899
        $uid = (int)$uid;
4900
        if (!$GLOBALS['TCA'][$table] || !$uid) {
4901
            $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions. [' . $this->BE_USER->errorMsg . ']');
4902
            return;
4903
        }
4904
        // Skip processing already deleted records
4905
        if (!$forceHardDelete && $this->hasDeletedRecord($table, $uid)) {
4906
            return;
4907
        }
4908
4909
        // Checking if there is anything else disallowing deleting the record by checking if editing is allowed
4910
        $fullLanguageAccessCheck = true;
4911
        if ($table === 'pages') {
4912
            // If this is a page translation, the full language access check should not be done
4913
            $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
4914
            if ($defaultLanguagePageId !== $uid) {
4915
                $fullLanguageAccessCheck = false;
4916
            }
4917
        }
4918
        $hasEditAccess = $this->BE_USER->recordEditAccessInternals($table, $uid, false, $forceHardDelete, $fullLanguageAccessCheck);
4919
        if (!$hasEditAccess) {
4920
            $this->log($table, $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to delete record without delete-permissions');
4921
            return;
4922
        }
4923
        if ($table === 'pages') {
4924
            $perms = Permission::PAGE_DELETE;
4925
        } elseif ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
4926
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
4927
            $perms = Permission::PAGE_EDIT;
4928
        } else {
4929
            $perms = Permission::CONTENT_EDIT;
4930
        }
4931
        if (!$noRecordCheck && !$this->doesRecordExist($table, $uid, $perms)) {
4932
            return;
4933
        }
4934
4935
        $recordToDelete = [];
4936
        $recordWorkspaceId = 0;
4937
        if (BackendUtility::isTableWorkspaceEnabled($table)) {
4938
            $recordToDelete = BackendUtility::getRecord($table, $uid);
4939
            $recordWorkspaceId = (int)($recordToDelete['t3ver_wsid'] ?? 0);
4940
        }
4941
4942
        // Clear cache before deleting the record, else the correct page cannot be identified by clear_cache
4943
        [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
4944
        $this->registerRecordIdForPageCacheClearing($table, $uid, $parentUid);
4945
        $deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? false;
4946
        $databaseErrorMessage = '';
4947
        if ($recordWorkspaceId > 0) {
4948
            // If this is a workspace record, use discard
4949
            $this->BE_USER->workspace = $recordWorkspaceId;
4950
            $this->discard($table, null, $recordToDelete);
4951
            // Switch user back to original workspace
4952
            $this->BE_USER->workspace = $currentUserWorkspace;
4953
        } elseif ($deleteField && !$forceHardDelete) {
4954
            $updateFields = [
4955
                $deleteField => 1,
4956
            ];
4957
            if ($GLOBALS['TCA'][$table]['ctrl']['tstamp']) {
4958
                $updateFields[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
4959
            }
4960
            // before deleting this record, check for child records or references
4961
            $this->deleteRecord_procFields($table, $uid);
4962
            try {
4963
                // Delete all l10n records as well
4964
                $this->deletedRecords[$table][] = (int)$uid;
4965
                $this->deleteL10nOverlayRecords($table, $uid);
4966
                GeneralUtility::makeInstance(ConnectionPool::class)
4967
                    ->getConnectionForTable($table)
4968
                    ->update($table, $updateFields, ['uid' => (int)$uid]);
4969
            } catch (DBALException $e) {
4970
                $databaseErrorMessage = $e->getPrevious()->getMessage();
4971
            }
4972
        } else {
4973
            // Delete the hard way...:
4974
            try {
4975
                $this->hardDeleteSingleRecord($table, (int)$uid);
4976
                $this->deletedRecords[$table][] = (int)$uid;
4977
                $this->deleteL10nOverlayRecords($table, $uid);
4978
            } catch (DBALException $e) {
4979
                $databaseErrorMessage = $e->getPrevious()->getMessage();
4980
            }
4981
        }
4982
        if ($this->enableLogging) {
4983
            $state = SystemLogDatabaseAction::DELETE;
4984
            if ($databaseErrorMessage === '') {
4985
                if ($forceHardDelete) {
4986
                    $message = 'Record \'%s\' (%s) was deleted unrecoverable from page \'%s\' (%s)';
4987
                } else {
4988
                    $message = 'Record \'%s\' (%s) was deleted from page \'%s\' (%s)';
4989
                }
4990
                $propArr = $this->getRecordProperties($table, $uid);
4991
                $pagePropArr = $this->getRecordProperties('pages', $propArr['pid']);
4992
4993
                $this->log($table, $uid, $state, 0, SystemLogErrorClassification::MESSAGE, $message, 0, [
4994
                    $propArr['header'],
4995
                    $table . ':' . $uid,
4996
                    $pagePropArr['header'],
4997
                    $propArr['pid'],
4998
                ], $propArr['event_pid']);
4999
            } else {
5000
                $this->log($table, $uid, $state, 0, SystemLogErrorClassification::SYSTEM_ERROR, $databaseErrorMessage);
5001
            }
5002
        }
5003
5004
        // Add history entry
5005
        $this->getRecordHistoryStore()->deleteRecord($table, $uid, $this->correlationId);
5006
5007
        // Update reference index with table/uid on left side (recuid)
5008
        $this->updateRefIndex($table, $uid);
5009
        // Update reference index with table/uid on right side (ref_uid). Important if children of a relation are deleted.
5010
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $currentUserWorkspace);
5011
    }
5012
5013
    /**
5014
     * Used to delete page because it will check for branch below pages and disallowed tables on the page as well.
5015
     *
5016
     * @param int $uid Page id
5017
     * @param bool $force If TRUE, pages are not checked for permission.
5018
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
5019
     * @param bool $deleteRecordsOnPage If false, records on the page will not be deleted (edge case while swapping workspaces)
5020
     * @internal should only be used from within DataHandler
5021
     */
5022
    public function deletePages($uid, $force = false, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5023
    {
5024
        $uid = (int)$uid;
5025
        if ($uid === 0) {
5026
            $this->log('pages', $uid, SystemLogDatabaseAction::DELETE, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'Deleting all pages starting from the root-page is disabled.', -1, [], 0);
5027
            return;
5028
        }
5029
        // Getting list of pages to delete:
5030
        if ($force) {
5031
            // Returns the branch WITHOUT permission checks (0 secures that), so it cannot return -1
5032
            $pageIdsInBranch = $this->doesBranchExist('', $uid, 0, true);
5033
            $res = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
5034
        } else {
5035
            $res = $this->canDeletePage($uid);
5036
        }
5037
        // Perform deletion if not error:
5038
        if (is_array($res)) {
5039
            foreach ($res as $deleteId) {
5040
                $this->deleteSpecificPage($deleteId, $forceHardDelete, $deleteRecordsOnPage);
5041
            }
5042
        } else {
5043
            $this->log(
5044
                'pages',
5045
                $uid,
5046
                SystemLogDatabaseAction::DELETE,
5047
                0,
5048
                SystemLogErrorClassification::SYSTEM_ERROR,
5049
                $res,
5050
                -1,
5051
                [$res],
5052
            );
5053
        }
5054
    }
5055
5056
    /**
5057
     * Delete a page (or set deleted field to 1) and all records on it.
5058
     *
5059
     * @param int $uid Page id
5060
     * @param bool $forceHardDelete If TRUE, the "deleted" flag is ignored if applicable for record and the record is deleted COMPLETELY!
5061
     * @param bool $deleteRecordsOnPage If false, records on the page will not be deleted (edge case while swapping workspaces)
5062
     * @internal
5063
     * @see deletePages()
5064
     */
5065
    public function deleteSpecificPage($uid, $forceHardDelete = false, bool $deleteRecordsOnPage = true)
5066
    {
5067
        $uid = (int)$uid;
5068
        if (!$uid) {
5069
            // Early void return on invalid uid
5070
            return;
5071
        }
5072
        $forceHardDelete = (bool)$forceHardDelete;
5073
5074
        // Delete either a default language page or a translated page
5075
        $pageIdInDefaultLanguage = $this->getDefaultLanguagePageId($uid);
5076
        $isPageTranslation = false;
5077
        $pageLanguageId = 0;
5078
        if ($pageIdInDefaultLanguage !== $uid) {
5079
            // For translated pages, translated records in other tables (eg. tt_content) for the
5080
            // to-delete translated page have their pid field set to the uid of the default language record,
5081
            // NOT the uid of the translated page record.
5082
            // If a translated page is deleted, only translations of records in other tables of this language
5083
            // should be deleted. The code checks if the to-delete page is a translated page and
5084
            // adapts the query for other tables to use the uid of the default language page as pid together
5085
            // with the language id of the translated page.
5086
            $isPageTranslation = true;
5087
            $pageLanguageId = $this->pageInfo($uid, $GLOBALS['TCA']['pages']['ctrl']['languageField']);
5088
        }
5089
5090
        if ($deleteRecordsOnPage) {
5091
            $tableNames = $this->compileAdminTables();
5092
            foreach ($tableNames as $table) {
5093
                if ($table === 'pages' || ($isPageTranslation && !BackendUtility::isTableLocalizable($table))) {
5094
                    // Skip pages table. And skip table if not translatable, but a translated page is deleted
5095
                    continue;
5096
                }
5097
5098
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5099
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5100
                $queryBuilder
5101
                    ->select('uid')
5102
                    ->from($table)
5103
                    // order by uid is needed here to process possible live records first - overlays always
5104
                    // have a higher uid. Otherwise dbms like postgres may return rows in arbitrary order,
5105
                    // leading to hard to debug issues. This is especially relevant for the
5106
                    // discardWorkspaceVersionsOfRecord() call below.
5107
                    ->addOrderBy('uid');
5108
5109
                if ($isPageTranslation) {
5110
                    // Only delete records in the specified language
5111
                    $queryBuilder->where(
5112
                        $queryBuilder->expr()->eq(
5113
                            'pid',
5114
                            $queryBuilder->createNamedParameter($pageIdInDefaultLanguage, \PDO::PARAM_INT)
5115
                        ),
5116
                        $queryBuilder->expr()->eq(
5117
                            $GLOBALS['TCA'][$table]['ctrl']['languageField'],
5118
                            $queryBuilder->createNamedParameter($pageLanguageId, \PDO::PARAM_INT)
5119
                        )
5120
                    );
5121
                } else {
5122
                    // Delete all records on this page
5123
                    $queryBuilder->where(
5124
                        $queryBuilder->expr()->eq(
5125
                            'pid',
5126
                            $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5127
                        )
5128
                    );
5129
                }
5130
5131
                $currentUserWorkspace = (int)$this->BE_USER->workspace;
5132
                if ($currentUserWorkspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5133
                    // If we are in a workspace, make sure only records of this workspace are deleted.
5134
                    $queryBuilder->andWhere(
5135
                        $queryBuilder->expr()->eq(
5136
                            't3ver_wsid',
5137
                            $queryBuilder->createNamedParameter($currentUserWorkspace, \PDO::PARAM_INT)
5138
                        )
5139
                    );
5140
                }
5141
5142
                $statement = $queryBuilder->execute();
5143
5144
                while ($row = $statement->fetchAssociative()) {
5145
                    // Delete any further workspace overlays of the record in question, then delete the record.
5146
                    $this->discardWorkspaceVersionsOfRecord($table, $row['uid']);
5147
                    $this->deleteRecord($table, $row['uid'], true, $forceHardDelete);
5148
                }
5149
            }
5150
        }
5151
5152
        // Delete any further workspace overlays of the record in question, then delete the record.
5153
        $this->discardWorkspaceVersionsOfRecord('pages', $uid);
5154
        $this->deleteRecord('pages', $uid, true, $forceHardDelete);
5155
    }
5156
5157
    /**
5158
     * Used to evaluate if a page can be deleted
5159
     *
5160
     * @param int $uid Page id
5161
     * @return int[]|string If array: List of page uids to traverse and delete (means OK), if string: error message.
5162
     * @internal should only be used from within DataHandler
5163
     */
5164
    public function canDeletePage($uid)
5165
    {
5166
        $uid = (int)$uid;
5167
        $isTranslatedPage = null;
5168
5169
        // If we may at all delete this page
5170
        // If this is a page translation, do the check against the perms_* of the default page
5171
        // Because it is currently only deleting the translation
5172
        $defaultLanguagePageId = $this->getDefaultLanguagePageId($uid);
5173
        if ($defaultLanguagePageId !== $uid) {
5174
            if ($this->doesRecordExist('pages', (int)$defaultLanguagePageId, Permission::PAGE_DELETE)) {
5175
                $isTranslatedPage = true;
5176
            } else {
5177
                return 'Attempt to delete page without permissions';
5178
            }
5179
        } elseif (!$this->doesRecordExist('pages', $uid, Permission::PAGE_DELETE)) {
5180
            return 'Attempt to delete page without permissions';
5181
        }
5182
5183
        $pageIdsInBranch = $this->doesBranchExist('', $uid, Permission::PAGE_DELETE, true);
5184
5185
        if ($pageIdsInBranch === -1) {
5186
            return 'Attempt to delete pages in branch without permissions';
5187
        }
5188
5189
        $pagesInBranch = GeneralUtility::intExplode(',', $pageIdsInBranch . $uid, true);
5190
5191
        if ($disallowedTables = $this->checkForRecordsFromDisallowedTables($pagesInBranch)) {
5192
            return 'Attempt to delete records from disallowed tables (' . implode(', ', $disallowedTables) . ')';
5193
        }
5194
5195
        foreach ($pagesInBranch as $pageInBranch) {
5196
            if (!$this->BE_USER->recordEditAccessInternals('pages', $pageInBranch, false, false, $isTranslatedPage ? false : true)) {
5197
                return 'Attempt to delete page which has prohibited localizations.';
5198
            }
5199
        }
5200
        return $pagesInBranch;
5201
    }
5202
5203
    /**
5204
     * Returns TRUE if record CANNOT be deleted, otherwise FALSE. Used to check before the versioning API allows a record to be marked for deletion.
5205
     *
5206
     * @param string $table Record Table
5207
     * @param int $id Record UID
5208
     * @return string Returns a string IF there is an error (error string explaining). FALSE means record can be deleted
5209
     * @internal should only be used from within DataHandler
5210
     */
5211
    public function cannotDeleteRecord($table, $id)
5212
    {
5213
        if ($table === 'pages') {
5214
            $res = $this->canDeletePage($id);
5215
            return is_array($res) ? false : $res;
5216
        }
5217
        if ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap)) {
5218
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
5219
            $perms = Permission::PAGE_EDIT;
5220
        } else {
5221
            $perms = Permission::CONTENT_EDIT;
5222
        }
5223
        return $this->doesRecordExist($table, $id, $perms) ? false : 'No permission to delete record';
5224
    }
5225
5226
    /**
5227
     * Before a record is deleted, check if it has references such as inline type or MM references.
5228
     * If so, set these child records also to be deleted.
5229
     *
5230
     * @param string $table Record Table
5231
     * @param int $uid Record UID
5232
     * @see deleteRecord()
5233
     * @internal should only be used from within DataHandler
5234
     */
5235
    public function deleteRecord_procFields($table, $uid)
5236
    {
5237
        $conf = $GLOBALS['TCA'][$table]['columns'];
5238
        $row = BackendUtility::getRecord($table, $uid, '*', '', false);
5239
        if (empty($row)) {
5240
            return;
5241
        }
5242
        foreach ($row as $field => $value) {
5243
            $this->deleteRecord_procBasedOnFieldType($table, $uid, $value, $conf[$field]['config'] ?? []);
5244
        }
5245
    }
5246
5247
    /**
5248
     * Process fields of a record to be deleted and search for special handling, like
5249
     * inline type, MM records, etc.
5250
     *
5251
     * @param string $table Record Table
5252
     * @param int $uid Record UID
5253
     * @param string $value Record field value
5254
     * @param array $conf TCA configuration of current field
5255
     * @see deleteRecord()
5256
     * @internal should only be used from within DataHandler
5257
     */
5258
    public function deleteRecord_procBasedOnFieldType($table, $uid, $value, $conf): void
5259
    {
5260
        if (!isset($conf['type'])) {
5261
            return;
5262
        }
5263
        if ($conf['type'] === 'inline') {
5264
            $foreign_table = $conf['foreign_table'];
5265
            if ($foreign_table) {
5266
                $inlineType = $this->getInlineFieldType($conf);
5267
                if ($inlineType === 'list' || $inlineType === 'field') {
5268
                    /** @var RelationHandler $dbAnalysis */
5269
                    $dbAnalysis = $this->createRelationHandlerInstance();
5270
                    $dbAnalysis->start($value, $conf['foreign_table'], '', $uid, $table, $conf);
5271
                    $dbAnalysis->undeleteRecord = true;
5272
5273
                    $enableCascadingDelete = true;
5274
                    // non type save comparison is intended!
5275
                    if (isset($conf['behaviour']['enableCascadingDelete']) && $conf['behaviour']['enableCascadingDelete'] == false) {
5276
                        $enableCascadingDelete = false;
5277
                    }
5278
5279
                    // Walk through the items and remove them
5280
                    foreach ($dbAnalysis->itemArray as $v) {
5281
                        if ($enableCascadingDelete) {
5282
                            $this->deleteAction($v['table'], $v['id']);
5283
                        }
5284
                    }
5285
                }
5286
            }
5287
        } elseif ($this->isReferenceField($conf)) {
5288
            $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
5289
            $dbAnalysis = $this->createRelationHandlerInstance();
5290
            $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $uid, $table, $conf);
5291
            foreach ($dbAnalysis->itemArray as $v) {
5292
                $this->updateRefIndex($v['table'], $v['id']);
5293
            }
5294
        }
5295
    }
5296
5297
    /**
5298
     * Find l10n-overlay records and perform the requested delete action for these records.
5299
     *
5300
     * @param string $table Record Table
5301
     * @param int $uid Record UID
5302
     * @internal should only be used from within DataHandler
5303
     */
5304
    public function deleteL10nOverlayRecords($table, $uid)
5305
    {
5306
        // Check whether table can be localized
5307
        if (!BackendUtility::isTableLocalizable($table)) {
5308
            return;
5309
        }
5310
5311
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5312
        $queryBuilder->getRestrictions()
5313
            ->removeAll()
5314
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
5315
            ->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, (int)$this->BE_USER->workspace));
5316
5317
        $queryBuilder->select('*')
5318
            ->from($table)
5319
            ->where(
5320
                $queryBuilder->expr()->eq(
5321
                    $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5322
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5323
                )
5324
            );
5325
5326
        $result = $queryBuilder->execute();
5327
        while ($record = $result->fetchAssociative()) {
5328
            // Ignore workspace delete placeholders. Those records have been marked for
5329
            // deletion before - deleting them again in a workspace would revert that state.
5330
            if ((int)$this->BE_USER->workspace > 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
5331
                BackendUtility::workspaceOL($table, $record, $this->BE_USER->workspace);
5332
                if (VersionState::cast($record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER)) {
5333
                    continue;
5334
                }
5335
            }
5336
            $this->deleteAction($table, (int)$record['t3ver_oid'] > 0 ? (int)$record['t3ver_oid'] : (int)$record['uid']);
5337
        }
5338
    }
5339
5340
    /*********************************************
5341
     *
5342
     * Cmd: undelete / restore
5343
     *
5344
     ********************************************/
5345
5346
    /**
5347
     * Restore live records by setting soft-delete flag to 0.
5348
     *
5349
     * Usually only used by ext:recycler.
5350
     * Connected relations (eg. inline) are restored, too.
5351
     * Additional existing localizations are not restored.
5352
     *
5353
     * @param string $table Record table name
5354
     * @param int $uid Record uid
5355
     */
5356
    protected function undeleteRecord(string $table, int $uid): void
5357
    {
5358
        $record = BackendUtility::getRecord($table, $uid, '*', '', false);
5359
        $deleteField = (string)($GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '');
5360
        $timestampField = (string)($GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? '');
5361
5362
        if ($record === null
5363
            || $deleteField === ''
5364
            || !isset($record[$deleteField])
5365
            || (bool)$record[$deleteField] === false
5366
            || ($timestampField !== '' && !isset($record[$timestampField]))
5367
            || (int)$this->BE_USER->workspace > 0
5368
            || (BackendUtility::isTableWorkspaceEnabled($table) && (int)($record['t3ver_wsid'] ?? 0) > 0)
5369
        ) {
5370
            // Return early and silently, if:
5371
            // * Record not found
5372
            // * Table is not soft-delete aware
5373
            // * Record does not have deleted field - db analyzer not up-to-date?
5374
            // * Record is not deleted - may eventually happen via recursion with self referencing records?
5375
            // * Table is tstamp aware, but field does not exist - db analyzer not up-to-date?
5376
            // * User is in a workspace - does not make sense
5377
            // * Record is in a workspace - workspace records are not soft-delete aware
5378
            return;
5379
        }
5380
5381
        $recordPid = (int)($record['pid'] ?? 0);
5382
        if ($recordPid > 0) {
5383
            // Record is not on root level. Parent page record must exist and must not be deleted itself.
5384
            $page = BackendUtility::getRecord('pages', $recordPid, 'deleted', '', false);
5385
            if ($page === null || !isset($page['deleted']) || (bool)$page['deleted'] === true) {
5386
                $this->log(
5387
                    $table,
5388
                    $uid,
5389
                    SystemLogDatabaseAction::DELETE,
5390
                    0,
5391
                    SystemLogErrorClassification::USER_ERROR,
5392
                    sprintf('Record "%s:%s" can\'t be restored: The page:%s containing it does not exist or is soft-deleted.', $table, $uid, $recordPid),
5393
                    0,
5394
                    [],
5395
                    $recordPid
5396
                );
5397
                return;
5398
            }
5399
        }
5400
5401
        // @todo: When restoring a not-default language record, it should be verified the default language
5402
        // @todo: record is *not* set to deleted. Maybe even verify a possible l10n_source chain is not deleted?
5403
5404
        if (!$this->BE_USER->recordEditAccessInternals($table, $record, false, true)) {
5405
            // User misses access permissions to record
5406
            $this->log(
5407
                $table,
5408
                $uid,
5409
                SystemLogDatabaseAction::DELETE,
5410
                0,
5411
                SystemLogErrorClassification::USER_ERROR,
5412
                sprintf('Record "%s:%s" can\'t be restored: Insufficient user permissions.', $table, $uid),
5413
                0,
5414
                [],
5415
                $recordPid
5416
            );
5417
            return;
5418
        }
5419
5420
        // Restore referenced child records
5421
        $this->undeleteRecordRelations($table, $uid, $record);
5422
5423
        // Restore record
5424
        $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...
5425
        if ($timestampField !== '') {
5426
            $updateFields[$timestampField] = $GLOBALS['EXEC_TIME'];
5427
        }
5428
        GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table)
5429
            ->update(
5430
                $table,
5431
                $updateFields,
5432
                ['uid' => $uid]
5433
            );
5434
5435
        if ($this->enableLogging) {
5436
            $this->log(
5437
                $table,
5438
                $uid,
5439
                SystemLogDatabaseAction::INSERT,
5440
                0,
5441
                SystemLogErrorClassification::MESSAGE,
5442
                sprintf('Record "%s:%s" was restored on page:%s', $table, $uid, $recordPid),
5443
                0,
5444
                [],
5445
                $recordPid
5446
            );
5447
        }
5448
5449
        // Register cache clearing of page, or parent page if a page is restored.
5450
        $this->registerRecordIdForPageCacheClearing($table, $uid, $recordPid);
5451
        // Add history entry
5452
        $this->getRecordHistoryStore()->undeleteRecord($table, $uid, $this->correlationId);
5453
        // Update reference index with table/uid on left side (recuid)
5454
        $this->updateRefIndex($table, $uid);
5455
        // Update reference index with table/uid on right side (ref_uid). Important if children of a relation were restored.
5456
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, 0);
5457
    }
5458
5459
    /**
5460
     * Check if a to-restore record has inline references and restore them.
5461
     *
5462
     * @param string $table Record table name
5463
     * @param int $uid Record uid
5464
     * @param array $record Record row
5465
     * @todo: Add functional test undelete coverage to verify details, some details seem to be missing.
5466
     */
5467
    protected function undeleteRecordRelations(string $table, int $uid, array $record): void
5468
    {
5469
        foreach ($record as $fieldName => $value) {
5470
            $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'] ?? [];
5471
            $fieldType = (string)($fieldConfig['type'] ?? '');
5472
            if (empty($fieldConfig) || !is_array($fieldConfig) || $fieldType === '') {
5473
                continue;
5474
            }
5475
            $foreignTable = (string)($fieldConfig['foreign_table'] ?? '');
5476
            if ($fieldType === 'inline') {
5477
                // @todo: Inline MM not handled here, and what about group / select?
5478
                if ($foreignTable === ''
5479
                    || !in_array($this->getInlineFieldType($fieldConfig), ['list', 'field'], true)
5480
                ) {
5481
                    continue;
5482
                }
5483
                $relationHandler = $this->createRelationHandlerInstance();
5484
                $relationHandler->start($value, $foreignTable, '', $uid, $table, $fieldConfig);
5485
                $relationHandler->undeleteRecord = true;
5486
                foreach ($relationHandler->itemArray as $reference) {
5487
                    $this->undeleteRecord($reference['table'], (int)$reference['id']);
5488
                }
5489
            } elseif ($this->isReferenceField($fieldConfig)) {
5490
                $allowedTables = $fieldType === 'group' ? ($fieldConfig['allowed'] ?? '') : $foreignTable;
5491
                $relationHandler = $this->createRelationHandlerInstance();
5492
                $relationHandler->start($value, $allowedTables, $fieldConfig['MM'] ?? '', $uid, $table, $fieldConfig);
5493
                foreach ($relationHandler->itemArray as $reference) {
5494
                    // @todo: Unsure if this is ok / enough. Needs coverage.
5495
                    $this->updateRefIndex($reference['table'], $reference['id']);
5496
                }
5497
            }
5498
        }
5499
    }
5500
5501
    /*********************************************
5502
     *
5503
     * Cmd: Workspace discard & flush
5504
     *
5505
     ********************************************/
5506
5507
    /**
5508
     * Discard a versioned record from this workspace. This deletes records from the database - no soft delete.
5509
     * This main entry method is called recursive for sub pages, localizations, relations and records on a page.
5510
     * The method checks user access and gathers facts about this record to hand the deletion over to detail methods.
5511
     *
5512
     * The incoming $uid or $row can be anything: The workspace of current user is respected and only records
5513
     * of current user workspace are discarded. If giving a live record uid, the versioned overly will be fetched.
5514
     *
5515
     * @param string $table Database table name
5516
     * @param int|null $uid Uid of live or versioned record to be discarded, or null if $record is given
5517
     * @param array|null $record Record row that should be discarded. Used instead of $uid within recursion.
5518
     * @internal should only be used from within DataHandler
5519
     */
5520
    public function discard(string $table, ?int $uid, array $record = null): void
5521
    {
5522
        if ($uid === null && $record === null) {
5523
            throw new \RuntimeException('Either record $uid or $record row must be given', 1600373491);
5524
        }
5525
5526
        // Fetch record we are dealing with if not given
5527
        if ($record === null) {
5528
            $record = BackendUtility::getRecord($table, (int)$uid);
5529
        }
5530
        if (!is_array($record)) {
5531
            return;
5532
        }
5533
        $uid = (int)$record['uid'];
5534
5535
        // Call hook and return if hook took care of the element
5536
        $recordWasDiscarded = false;
5537
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass'] ?? [] as $className) {
5538
            $hookObj = GeneralUtility::makeInstance($className);
5539
            if (method_exists($hookObj, 'processCmdmap_discardAction')) {
5540
                $hookObj->processCmdmap_discardAction($table, $uid, $record, $recordWasDiscarded);
5541
            }
5542
        }
5543
5544
        $userWorkspace = (int)$this->BE_USER->workspace;
5545
        if ($recordWasDiscarded
5546
            || $userWorkspace === 0
5547
            || !BackendUtility::isTableWorkspaceEnabled($table)
5548
            || $this->hasDeletedRecord($table, $uid)
5549
        ) {
5550
            return;
5551
        }
5552
5553
        // Gather versioned record
5554
        if ((int)$record['t3ver_wsid'] === 0) {
5555
            $record = BackendUtility::getWorkspaceVersionOfRecord($userWorkspace, $table, $uid);
5556
        }
5557
        if (!is_array($record)) {
5558
            return;
5559
        }
5560
        $versionRecord = $record;
5561
5562
        // User access checks
5563
        if ($userWorkspace !== (int)$versionRecord['t3ver_wsid']) {
5564
            $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: Different workspace');
5565
            return;
5566
        }
5567
        if ($errorCode = $this->workspaceCannotEditOfflineVersion($table, $versionRecord)) {
5568
            $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: ' . $errorCode);
5569
            return;
5570
        }
5571
        if (!$this->checkRecordUpdateAccess($table, $versionRecord['uid'])) {
5572
            $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: User has no edit access');
5573
            return;
5574
        }
5575
        $fullLanguageAccessCheck = !($table === 'pages' && (int)$versionRecord[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']] !== 0);
5576
        if (!$this->BE_USER->recordEditAccessInternals($table, $versionRecord, false, true, $fullLanguageAccessCheck)) {
5577
            $this->log($table, $versionRecord['uid'], SystemLogDatabaseAction::DISCARD, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to discard workspace record ' . $table . ':' . $versionRecord['uid'] . ' failed: User has no delete access');
5578
            return;
5579
        }
5580
5581
        // Perform discard operations
5582
        $versionState = VersionState::cast($versionRecord['t3ver_state']);
5583
        if ($table === 'pages' && $versionState->equals(VersionState::NEW_PLACEHOLDER)) {
5584
            // When discarding a new page, there can be new sub pages and new records.
5585
            // Those need to be discarded, otherwise they'd end up as records without parent page.
5586
            $this->discardSubPagesAndRecordsOnPage($versionRecord);
5587
        }
5588
5589
        $this->discardLocalizationOverlayRecords($table, $versionRecord);
5590
        $this->discardRecordRelations($table, $versionRecord);
5591
        $this->discardCsvReferencesToRecord($table, $versionRecord);
5592
        $this->hardDeleteSingleRecord($table, (int)$versionRecord['uid']);
5593
        $this->deletedRecords[$table][] = (int)$versionRecord['uid'];
5594
        $this->registerReferenceIndexRowsForDrop($table, (int)$versionRecord['uid'], $userWorkspace);
5595
        $this->getRecordHistoryStore()->deleteRecord($table, (int)$versionRecord['uid'], $this->correlationId);
5596
        $this->log(
5597
            $table,
5598
            (int)$versionRecord['uid'],
5599
            SystemLogDatabaseAction::DELETE,
5600
            0,
5601
            SystemLogErrorClassification::MESSAGE,
5602
            'Record ' . $table . ':' . $versionRecord['uid'] . ' was deleted unrecoverable from page ' . $versionRecord['pid'],
5603
            0,
5604
            [],
5605
            (int)$versionRecord['pid']
5606
        );
5607
    }
5608
5609
    /**
5610
     * Also discard any sub pages and records of a new parent page if this page is discarded.
5611
     * Discarding only in specific localization, if needed.
5612
     *
5613
     * @param array $page Page record row
5614
     */
5615
    protected function discardSubPagesAndRecordsOnPage(array $page): void
5616
    {
5617
        $isLocalizedPage = false;
5618
        $sysLanguageId = (int)$page[$GLOBALS['TCA']['pages']['ctrl']['languageField']];
5619
        $versionState = VersionState::cast($page['t3ver_state']);
5620
        if ($sysLanguageId > 0) {
5621
            // New or moved localized page.
5622
            // Discard records on this page localization, but no sub pages.
5623
            // Records of a translated page have the pid set to the default language page uid. Found in l10n_parent.
5624
            // @todo: Discard other page translations that inherit from this?! (l10n_source field)
5625
            $isLocalizedPage = true;
5626
            $pid = (int)$page[$GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField']];
5627
        } elseif ($versionState->equals(VersionState::NEW_PLACEHOLDER)) {
5628
            // New default language page.
5629
            // Discard any sub pages and all other records of this page, including any page localizations.
5630
            // The t3ver_state=1 record is incoming here. Records on this page have their pid field set to the uid
5631
            // of this record. So, since t3ver_state=1 does not have an online counter-part, the actual UID is used here.
5632
            $pid = (int)$page['uid'];
5633
        } else {
5634
            // Moved default language page.
5635
            // Discard any sub pages and all other records of this page, including any page localizations.
5636
            $pid = (int)$page['t3ver_oid'];
5637
        }
5638
        $tables = $this->compileAdminTables();
5639
        foreach ($tables as $table) {
5640
            if (($isLocalizedPage && $table === 'pages')
5641
                || ($isLocalizedPage && !BackendUtility::isTableLocalizable($table))
5642
                || !BackendUtility::isTableWorkspaceEnabled($table)
5643
            ) {
5644
                continue;
5645
            }
5646
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5647
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5648
            $queryBuilder->select('*')
5649
                ->from($table)
5650
                ->where(
5651
                    $queryBuilder->expr()->eq(
5652
                        'pid',
5653
                        $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)
5654
                    ),
5655
                    $queryBuilder->expr()->eq(
5656
                        't3ver_wsid',
5657
                        $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, \PDO::PARAM_INT)
5658
                    )
5659
                );
5660
            if ($isLocalizedPage) {
5661
                // Add sys_language_uid = x restriction if discarding a localized page
5662
                $queryBuilder->andWhere(
5663
                    $queryBuilder->expr()->eq(
5664
                        $GLOBALS['TCA'][$table]['ctrl']['languageField'],
5665
                        $queryBuilder->createNamedParameter($sysLanguageId, \PDO::PARAM_INT)
5666
                    )
5667
                );
5668
            }
5669
            $statement = $queryBuilder->execute();
5670
            while ($row = $statement->fetchAssociative()) {
5671
                $this->discard($table, null, $row);
5672
            }
5673
        }
5674
    }
5675
5676
    /**
5677
     * Discard record relations like inline and MM of a record.
5678
     *
5679
     * @param string $table Table name of this record
5680
     * @param array $record The record row to handle
5681
     */
5682
    protected function discardRecordRelations(string $table, array $record): void
5683
    {
5684
        foreach ($record as $field => $value) {
5685
            $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'] ?? null;
5686
            if (!isset($fieldConfig['type'])) {
5687
                continue;
5688
            }
5689
            if ($fieldConfig['type'] === 'inline') {
5690
                $foreignTable = $fieldConfig['foreign_table'] ?? null;
5691
                if (!$foreignTable
5692
                     || (isset($fieldConfig['behaviour']['enableCascadingDelete'])
5693
                        && (bool)$fieldConfig['behaviour']['enableCascadingDelete'] === false)
5694
                ) {
5695
                    continue;
5696
                }
5697
                $inlineType = $this->getInlineFieldType($fieldConfig);
5698
                if ($inlineType === 'list' || $inlineType === 'field') {
5699
                    $dbAnalysis = $this->createRelationHandlerInstance();
5700
                    $dbAnalysis->start($value, $fieldConfig['foreign_table'], '', (int)$record['uid'], $table, $fieldConfig);
5701
                    $dbAnalysis->undeleteRecord = true;
5702
                    foreach ($dbAnalysis->itemArray as $relationRecord) {
5703
                        $this->discard($relationRecord['table'], (int)$relationRecord['id']);
5704
                    }
5705
                }
5706
            } elseif ($this->isReferenceField($fieldConfig) && !empty($fieldConfig['MM'])) {
5707
                $this->discardMmRelations($table, $fieldConfig, $record);
5708
            }
5709
            // @todo not inline and not mm - probably not handled correctly and has no proper test coverage yet
5710
        }
5711
    }
5712
5713
    /**
5714
     * When the to-discard record is the target of a CSV group field of another table record,
5715
     * these records need to be updated to no longer point to the discarded record.
5716
     *
5717
     * Those referencing records are not very easy to find with only the to-discard record being available.
5718
     * The solution used here looks up records referencing the to-discard record by fetching a list of
5719
     * references from sys_refindex, where the to-discard record is on the right side (ref_* fields)
5720
     * and in the workspace the to-discard record lives in. The referencing record fields are then updated
5721
     * to drop the to-discard record from the CSV list.
5722
     *
5723
     * Using sys_refindex for this task is a bit risky: This would fail if a DataHandler call
5724
     * adds a reference to the record and requests discarding the record in one call - the refindex
5725
     * is always only updated at the very end of a DataHandler call, the logic below wouldn't catch
5726
     * this since it would be based on an outdated sys_refindex. The scenario however is of little use and
5727
     * not used in core, so it should be fine.
5728
     *
5729
     * @param string $table Table name of this record
5730
     * @param array $record The record row to handle
5731
     */
5732
    protected function discardCsvReferencesToRecord(string $table, array $record): void
5733
    {
5734
        // @see test workspaces Group Discard createContentAndCreateElementRelationAndDiscardElement
5735
        // Records referencing the to-discard record.
5736
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_refindex');
5737
        $statement = $queryBuilder->select('tablename', 'recuid', 'field')
5738
            ->from('sys_refindex')
5739
            ->where(
5740
                $queryBuilder->expr()->eq('workspace', $queryBuilder->createNamedParameter($record['t3ver_wsid'], \PDO::PARAM_INT)),
5741
                $queryBuilder->expr()->eq('ref_table', $queryBuilder->createNamedParameter($table)),
5742
                $queryBuilder->expr()->eq('ref_uid', $queryBuilder->createNamedParameter($record['uid'], \PDO::PARAM_INT))
5743
            )
5744
            ->execute();
5745
        while ($row = $statement->fetchAssociative()) {
5746
            // For each record referencing the to-discard record, see if it is a CSV group field definition.
5747
            // If so, update that record to drop both the possible "uid" and "table_name_uid" variants from the list.
5748
            $fieldTca = $GLOBALS['TCA'][$row['tablename']]['columns'][$row['field']]['config'] ?? [];
5749
            $groupAllowed = GeneralUtility::trimExplode(',', $fieldTca['allowed'] ?? '', true);
5750
            // @todo: "select" may be affected too, but it has no coverage to show this, yet?
5751
            if (($fieldTca['type'] ?? '') === 'group'
5752
                && empty($fieldTca['MM'])
5753
                && (in_array('*', $groupAllowed, true) || in_array($table, $groupAllowed, true))
5754
            ) {
5755
                // Note it would be possible to a) update multiple records with only one DB call, and b) combine the
5756
                // select and update to a single update query by doing the CSV manipulation as string function in sql.
5757
                // That's harder to get right though and probably not *that* beneficial performance-wise since we're
5758
                // most likely dealing with a very small number of records here anyways. Still, an optimization should
5759
                // be considered after we drop TCA 'prepend_tname' handling and always rely only on "table_name_uid"
5760
                // variant for CSV storage.
5761
5762
                // Get that record
5763
                $recordReferencingDiscardedRecord = BackendUtility::getRecord($row['tablename'], $row['recuid'], $row['field']);
5764
                if (!$recordReferencingDiscardedRecord) {
5765
                    continue;
5766
                }
5767
                // Drop "uid" and "table_name_uid" from list
5768
                $listOfRelatedRecords = GeneralUtility::trimExplode(',', $recordReferencingDiscardedRecord[$row['field']], true);
5769
                $listOfRelatedRecordsWithoutDiscardedRecord = array_diff($listOfRelatedRecords, [$record['uid'], $table . '_' . $record['uid']]);
5770
                if ($listOfRelatedRecords !== $listOfRelatedRecordsWithoutDiscardedRecord) {
5771
                    // Update record if list changed
5772
                    $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($row['tablename']);
5773
                    $queryBuilder->update($row['tablename'])
5774
                        ->set($row['field'], implode(',', $listOfRelatedRecordsWithoutDiscardedRecord))
5775
                        ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($row['recuid'], \PDO::PARAM_INT)))
5776
                        ->execute();
5777
                }
5778
            }
5779
        }
5780
    }
5781
5782
    /**
5783
     * When a workspace record row is discarded that has mm relations, existing mm table rows need
5784
     * to be deleted. The method performs the delete operation depending on TCA field configuration.
5785
     *
5786
     * @param string $table Table name of this record
5787
     * @param array $fieldConfig TCA configuration of this field
5788
     * @param array $record The full record of a left- or ride-side relation
5789
     */
5790
    protected function discardMmRelations(string $table, array $fieldConfig, array $record): void
5791
    {
5792
        $recordUid = (int)$record['uid'];
5793
        $mmTableName = $fieldConfig['MM'];
5794
        // left - non foreign - uid_local vs. right - foreign - uid_foreign decision
5795
        $relationUidFieldName = isset($fieldConfig['MM_opposite_field']) ? 'uid_foreign' : 'uid_local';
5796
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
5797
        $queryBuilder->delete($mmTableName)->where(
5798
            // uid_local = given uid OR uid_foreign = given uid
5799
            $queryBuilder->expr()->eq($relationUidFieldName, $queryBuilder->createNamedParameter($recordUid, \PDO::PARAM_INT))
5800
        );
5801
        if (!empty($fieldConfig['MM_table_where']) && is_string($fieldConfig['MM_table_where'])) {
5802
            if (GeneralUtility::makeInstance(Features::class)->isFeatureEnabled('runtimeDbQuotingOfTcaConfiguration')) {
5803
                $queryBuilder->andWhere(
5804
                    QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, QueryHelper::quoteDatabaseIdentifiers($queryBuilder->getConnection(), $fieldConfig['MM_table_where'])))
5805
                );
5806
            } else {
5807
                $queryBuilder->andWhere(
5808
                    QueryHelper::stripLogicalOperatorPrefix(str_replace('###THIS_UID###', (string)$recordUid, $fieldConfig['MM_table_where']))
5809
                );
5810
            }
5811
        }
5812
        $mmMatchFields = $fieldConfig['MM_match_fields'] ?? [];
5813
        foreach ($mmMatchFields as $fieldName => $fieldValue) {
5814
            $queryBuilder->andWhere(
5815
                $queryBuilder->expr()->eq($fieldName, $queryBuilder->createNamedParameter($fieldValue, \PDO::PARAM_STR))
5816
            );
5817
        }
5818
        $queryBuilder->execute();
5819
5820
        // refindex treatment for mm relation handling: If the to discard record is foreign side of an mm relation,
5821
        // there may be other refindex rows that become obsolete when that record is discarded. See Modify
5822
        // addCategoryRelation sys_category-29->tt_content-298. We thus register an update for references
5823
        // to this item (right side - ref_table, ref_uid) in reference index updater to catch these.
5824
        if ($relationUidFieldName === 'uid_foreign') {
5825
            $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $recordUid, (int)$record['t3ver_wsid']);
5826
        }
5827
    }
5828
5829
    /**
5830
     * Find localization overlays of a record and discard them.
5831
     *
5832
     * @param string $table Table of this record
5833
     * @param array $record Record row
5834
     */
5835
    protected function discardLocalizationOverlayRecords(string $table, array $record): void
5836
    {
5837
        if (!BackendUtility::isTableLocalizable($table)) {
5838
            return;
5839
        }
5840
        $uid = (int)$record['uid'];
5841
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
5842
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
5843
        $statement = $queryBuilder->select('*')
5844
            ->from($table)
5845
            ->where(
5846
                $queryBuilder->expr()->eq(
5847
                    $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'],
5848
                    $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)
5849
                ),
5850
                $queryBuilder->expr()->eq(
5851
                    't3ver_wsid',
5852
                    $queryBuilder->createNamedParameter((int)$this->BE_USER->workspace, \PDO::PARAM_INT)
5853
                )
5854
            )
5855
            ->execute();
5856
        while ($record = $statement->fetchAssociative()) {
5857
            $this->discard($table, null, $record);
5858
        }
5859
    }
5860
5861
    /*********************************************
5862
     *
5863
     * Cmd: Versioning
5864
     *
5865
     ********************************************/
5866
    /**
5867
     * Creates a new version of a record
5868
     * (Requires support in the table)
5869
     *
5870
     * @param string $table Table name
5871
     * @param int $id Record uid to versionize
5872
     * @param string $label Version label
5873
     * @param bool $delete If TRUE, the version is created to delete the record.
5874
     * @return int|null Returns the id of the new version (if any)
5875
     * @see copyRecord()
5876
     * @internal should only be used from within DataHandler
5877
     */
5878
    public function versionizeRecord($table, $id, $label, $delete = false)
5879
    {
5880
        $id = (int)$id;
5881
        // Stop any actions if the record is marked to be deleted:
5882
        // (this can occur if IRRE elements are versionized and child elements are removed)
5883
        if ($this->isElementToBeDeleted($table, $id)) {
5884
            return null;
5885
        }
5886
        if (!BackendUtility::isTableWorkspaceEnabled($table) || $id <= 0) {
5887
            $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Versioning is not supported for this table "' . $table . '" / ' . $id);
5888
            return null;
5889
        }
5890
5891
        // Fetch record with permission check
5892
        $row = $this->recordInfoWithPermissionCheck($table, $id, Permission::PAGE_SHOW);
5893
5894
        // This checks if the record can be selected which is all that a copy action requires.
5895
        if ($row === false) {
5896
            $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'The record does not exist or you don\'t have correct permissions to make a new version (copy) of this record "' . $table . ':' . $id . '"');
5897
            return null;
5898
        }
5899
5900
        // Record must be online record, otherwise we would create a version of a version
5901
        if (($row['t3ver_oid'] ?? 0) > 0) {
5902
            $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Record "' . $table . ':' . $id . '" you wanted to versionize was already a version in archive (record has an online ID)!');
5903
            return null;
5904
        }
5905
5906
        if ($delete && $this->cannotDeleteRecord($table, $id)) {
5907
            $this->log($table, $id, SystemLogDatabaseAction::VERSIONIZE, 0, SystemLogErrorClassification::USER_ERROR, 'Record cannot be deleted: ' . $this->cannotDeleteRecord($table, $id));
5908
            return null;
5909
        }
5910
5911
        // Set up the values to override when making a raw-copy:
5912
        $overrideArray = [
5913
            't3ver_oid' => $id,
5914
            't3ver_wsid' => $this->BE_USER->workspace,
5915
            't3ver_state' => (string)($delete ? new VersionState(VersionState::DELETE_PLACEHOLDER) : new VersionState(VersionState::DEFAULT_STATE)),
5916
            't3ver_stage' => 0,
5917
        ];
5918
        if ($GLOBALS['TCA'][$table]['ctrl']['editlock'] ?? false) {
5919
            $overrideArray[$GLOBALS['TCA'][$table]['ctrl']['editlock']] = 0;
5920
        }
5921
        // Checking if the record already has a version in the current workspace of the backend user
5922
        $versionRecord = ['uid' => null];
5923
        if ($this->BE_USER->workspace !== 0) {
5924
            // Look for version already in workspace:
5925
            $versionRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid');
5926
        }
5927
        // Create new version of the record and return the new uid
5928
        if (empty($versionRecord['uid'])) {
5929
            // Create raw-copy and return result:
5930
            // The information of the label to be used for the workspace record
5931
            // as well as the information whether the record shall be removed
5932
            // must be forwarded (creating delete placeholders on a workspace are
5933
            // done by copying the record and override several fields).
5934
            $workspaceOptions = [
5935
                'delete' => $delete,
5936
                'label' => $label,
5937
            ];
5938
            return $this->copyRecord_raw($table, $id, (int)$row['pid'], $overrideArray, $workspaceOptions);
5939
        }
5940
        // Reuse the existing record and return its uid
5941
        // (prior to TYPO3 CMS 6.2, an error was thrown here, which
5942
        // did not make much sense since the information is available)
5943
        return $versionRecord['uid'];
5944
    }
5945
5946
    /**
5947
     * Handle MM relations attached to a record when publishing a workspace record.
5948
     *
5949
     * Strategy:
5950
     * * Find all MM tables the record can be attached to by scanning TCA. Handle
5951
     *   flex form "first level" fields too, but skip scanning for MM relations in
5952
     *   container sections, since core does not support that since v7 - FormEngine
5953
     *   throws an exception in this case.
5954
     * * For each found MM table: Delete current MM rows of the live record, and
5955
     *   update MM rows of the workspace record to now point to the live record.
5956
     *
5957
     * @internal should only be used from within DataHandler
5958
     */
5959
    public function versionPublishManyToManyRelations(string $table, array $liveRecord, array $workspaceRecord, int $fromWorkspace): void
5960
    {
5961
        if (!is_array($GLOBALS['TCA'][$table]['columns'])) {
5962
            return;
5963
        }
5964
        $toDeleteRegistry = [];
5965
        $toUpdateRegistry = [];
5966
        foreach ($GLOBALS['TCA'][$table]['columns'] as $dbFieldName => $dbFieldConfig) {
5967
            if (empty($dbFieldConfig['config']['type'])) {
5968
                continue;
5969
            }
5970
            if (!empty($dbFieldConfig['config']['MM']) && $this->isReferenceField($dbFieldConfig['config'])) {
5971
                $toDeleteRegistry[] = $dbFieldConfig['config'];
5972
                $toUpdateRegistry[] = $dbFieldConfig['config'];
5973
            }
5974
            if ($dbFieldConfig['config']['type'] === 'flex') {
5975
                $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
5976
                // Find possible mm tables attached to live record flex from data structures, mark as to delete
5977
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $liveRecord);
5978
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5979
                foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
5980
                    foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
5981
                        if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
5982
                            $toDeleteRegistry[] = $flexFieldDefinition['TCEforms']['config'];
5983
                        }
5984
                    }
5985
                }
5986
                // Find possible mm tables attached to workspace record flex from data structures, mark as to update uid
5987
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier($dbFieldConfig, $table, $dbFieldName, $workspaceRecord);
5988
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5989
                foreach (($dataStructureArray['sheets'] ?? []) as $flexSheetDefinition) {
5990
                    foreach (($flexSheetDefinition['ROOT']['el'] ?? []) as $flexFieldDefinition) {
5991
                        if (is_array($flexFieldDefinition) && $this->flexFieldDefinitionIsMmRelation($flexFieldDefinition)) {
5992
                            $toUpdateRegistry[] = $flexFieldDefinition['TCEforms']['config'];
5993
                        }
5994
                    }
5995
                }
5996
            }
5997
        }
5998
5999
        // Delete mm table relations of live record
6000
        foreach ($toDeleteRegistry as $config) {
6001
            $uidFieldName = $this->mmRelationIsLocalSide($config) ? 'uid_local' : 'uid_foreign';
6002
            $mmTableName = $config['MM'];
6003
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6004
            $queryBuilder->delete($mmTableName);
6005
            $queryBuilder->where($queryBuilder->expr()->eq(
6006
                $uidFieldName,
6007
                $queryBuilder->createNamedParameter((int)$liveRecord['uid'], \PDO::PARAM_INT)
6008
            ));
6009
            if ($this->mmQueryShouldUseTablenamesColumn($config)) {
6010
                $queryBuilder->andWhere($queryBuilder->expr()->eq(
6011
                    'tablenames',
6012
                    $queryBuilder->createNamedParameter($table)
6013
                ));
6014
            }
6015
            $queryBuilder->execute();
6016
        }
6017
6018
        // Update mm table relations of workspace record to uid of live record
6019
        foreach ($toUpdateRegistry as $config) {
6020
            $mmRelationIsLocalSide = $this->mmRelationIsLocalSide($config);
6021
            $uidFieldName = $mmRelationIsLocalSide ? 'uid_local' : 'uid_foreign';
6022
            $mmTableName = $config['MM'];
6023
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($mmTableName);
6024
            $queryBuilder->update($mmTableName);
6025
            $queryBuilder->set($uidFieldName, (int)$liveRecord['uid'], true, \PDO::PARAM_INT);
6026
            $queryBuilder->where($queryBuilder->expr()->eq(
6027
                $uidFieldName,
6028
                $queryBuilder->createNamedParameter((int)$workspaceRecord['uid'], \PDO::PARAM_INT)
6029
            ));
6030
            if ($this->mmQueryShouldUseTablenamesColumn($config)) {
6031
                $queryBuilder->andWhere($queryBuilder->expr()->eq(
6032
                    'tablenames',
6033
                    $queryBuilder->createNamedParameter($table)
6034
                ));
6035
            }
6036
            $queryBuilder->execute();
6037
6038
            if (!$mmRelationIsLocalSide) {
6039
                // refindex treatment for mm relation handling: If the to publish record is foreign side of an mm relation, we need
6040
                // to instruct refindex updater to update all local side references for the live record the current workspace record
6041
                // has on foreign side. See ManyToMany Publish addCategoryRelation, this will create the sys_category-31->tt_content-297 entry.
6042
                $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$workspaceRecord['uid'], $fromWorkspace, 0);
6043
                // Similar, when in mm foreign side and relations are deleted in live during publish, other relations pointing to the
6044
                // same local side record may need updates due to different sorting, and the former refindex entry of the live record
6045
                // needs updates. See ManyToMany Publish deleteCategoryRelation scenario.
6046
                $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, (int)$liveRecord['uid'], 0);
6047
            }
6048
        }
6049
    }
6050
6051
    /**
6052
     * Find out if a given flex field definition is a relation with an MM relation.
6053
     * Helper of versionPublishManyToManyRelations().
6054
     */
6055
    private function flexFieldDefinitionIsMmRelation(array $flexFieldDefinition): bool
6056
    {
6057
        return ($flexFieldDefinition['type'] ?? '') !== 'array' // is a field, not a section
6058
            && is_array($flexFieldDefinition['TCEforms']['config'] ?? false) // config array exists
6059
            && $this->isReferenceField($flexFieldDefinition['TCEforms']['config']) // select, group, category
6060
            && !empty($flexFieldDefinition['TCEforms']['config']['MM']); // MM exists
6061
    }
6062
6063
    /**
6064
     * Find out if a query to an MM table should have a "tablenames=myTable" where. This
6065
     * is the case if we're looking at it from the foreign side and if the table must have
6066
     * "tablenames" column due to various TCA combinations.
6067
     * Helper of versionPublishManyToManyRelations().
6068
     */
6069
    private function mmQueryShouldUseTablenamesColumn(array $config): bool
6070
    {
6071
        if ($this->mmRelationIsLocalSide($config)) {
6072
            return false;
6073
        }
6074
        if ($config['type'] === 'group' && !empty($config['prepend_tname'])) {
6075
            // prepend_tname in MM on foreign side forces 'tablenames' column
6076
            // @todo: See if we can get rid of prepend_tname in MM altogether?
6077
            return true;
6078
        }
6079
        if ($config['type'] === 'group' && is_string($config['allowed'] ?? false)
6080
            && (str_contains($config['allowed'], ',') || $config['allowed'] === '*')
6081
        ) {
6082
            // 'allowed' with *, or more than one table
6083
            // @todo: Neither '*' nor 'multiple tables' make sense for MM on foreign side.
6084
            //        There is a hint in the docs about this, too. Sanitize in TCA bootstrap?!
6085
            return true;
6086
        }
6087
        $localSideTableName = $config['type'] === 'group' ? $config['allowed'] ?? '' : $config['foreign_table'] ?? '';
6088
        $localSideFieldName = $config['MM_opposite_field'] ?? '';
6089
        $localSideAllowed = $GLOBALS['TCA'][$localSideTableName]['columns'][$localSideFieldName]['config']['allowed'] ?? '';
6090
        // Local side with 'allowed' = '*' or multiple tables forces 'tablenames' column
6091
        return $localSideAllowed === '*' || str_contains($localSideAllowed, ',');
6092
    }
6093
6094
    /**
6095
     * Find out if we're looking at an MM relation from local or foreign side.
6096
     * Helper of versionPublishManyToManyRelations().
6097
     */
6098
    private function mmRelationIsLocalSide(array $config): bool
6099
    {
6100
        return empty($config['MM_opposite_field']);
6101
    }
6102
6103
    /*********************************************
6104
     *
6105
     * Cmd: Helper functions
6106
     *
6107
     ********************************************/
6108
6109
    /**
6110
     * Returns an instance of DataHandler for handling local datamaps/cmdmaps
6111
     *
6112
     * @return DataHandler
6113
     */
6114
    protected function getLocalTCE()
6115
    {
6116
        $copyTCE = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
6117
        $copyTCE->copyTree = $this->copyTree;
6118
        $copyTCE->enableLogging = $this->enableLogging;
6119
        // Transformations should NOT be carried out during copy
6120
        $copyTCE->dontProcessTransformations = true;
6121
        // make sure the isImporting flag is transferred, so all hooks know if
6122
        // the current process is an import process
6123
        $copyTCE->isImporting = $this->isImporting;
6124
        $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords;
6125
        $copyTCE->bypassWorkspaceRestrictions = $this->bypassWorkspaceRestrictions;
6126
        return $copyTCE;
6127
    }
6128
6129
    /**
6130
     * Processes the fields with references as registered during the copy process. This includes all FlexForm fields which had references.
6131
     * @internal should only be used from within DataHandler
6132
     */
6133
    public function remapListedDBRecords()
6134
    {
6135
        if (!empty($this->registerDBList)) {
6136
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
6137
            foreach ($this->registerDBList as $table => $records) {
6138
                foreach ($records as $uid => $fields) {
6139
                    $newData = [];
6140
                    $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
6141
                    $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate);
6142
                    foreach ($fields as $fieldName => $value) {
6143
                        $conf = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'];
6144
                        switch ($conf['type']) {
6145
                            case 'group':
6146
                            case 'select':
6147
                            case 'category':
6148
                                $vArray = $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6149
                                if (is_array($vArray)) {
6150
                                    $newData[$fieldName] = implode(',', $vArray);
6151
                                }
6152
                                break;
6153
                            case 'flex':
6154
                                if ($value === 'FlexForm_reference') {
6155
                                    // This will fetch the new row for the element
6156
                                    $origRecordRow = $this->recordInfo($table, $theUidToUpdate, '*');
6157
                                    if (is_array($origRecordRow)) {
6158
                                        BackendUtility::workspaceOL($table, $origRecordRow);
6159
                                        // Get current data structure and value array:
6160
                                        $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
6161
                                            ['config' => $conf],
6162
                                            $table,
6163
                                            $fieldName,
6164
                                            $origRecordRow
6165
                                        );
6166
                                        $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
6167
                                        $currentValueArray = GeneralUtility::xml2array($origRecordRow[$fieldName]);
6168
                                        // Do recursive processing of the XML data:
6169
                                        $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], $dataStructureArray, [$table, $theUidToUpdate, $fieldName], 'remapListedDBRecords_flexFormCallBack');
6170
                                        // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later):
6171
                                        if (is_array($currentValueArray['data'])) {
6172
                                            $newData[$fieldName] = $this->checkValue_flexArray2Xml($currentValueArray, true);
6173
                                        }
6174
                                    }
6175
                                }
6176
                                break;
6177
                            case 'inline':
6178
                                $this->remapListedDBRecords_procInline($conf, $value, $uid, $table);
6179
                                break;
6180
                            default:
6181
                                $this->logger->debug('Field type should not appear here: {type}', ['type' => $conf['type']]);
0 ignored issues
show
Bug introduced by
The method debug() 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

6181
                                $this->logger->/** @scrutinizer ignore-call */ 
6182
                                               debug('Field type should not appear here: {type}', ['type' => $conf['type']]);

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...
6182
                        }
6183
                    }
6184
                    // If any fields were changed, those fields are updated!
6185
                    if (!empty($newData)) {
6186
                        $this->updateDB($table, $theUidToUpdate_saveTo, $newData);
6187
                    }
6188
                }
6189
            }
6190
        }
6191
    }
6192
6193
    /**
6194
     * Callback function for traversing the FlexForm structure in relation to creating copied files of file relations inside of flex form structures.
6195
     *
6196
     * @param array $pParams Set of parameters in numeric array: table, uid, field
6197
     * @param array $dsConf TCA config for field (from Data Structure of course)
6198
     * @param string $dataValue Field value (from FlexForm XML)
6199
     * @return array Array where the "value" key carries the value.
6200
     * @see checkValue_flex_procInData_travDS()
6201
     * @see remapListedDBRecords()
6202
     * @internal should only be used from within DataHandler
6203
     */
6204
    public function remapListedDBRecords_flexFormCallBack($pParams, $dsConf, $dataValue)
6205
    {
6206
        // Extract parameters:
6207
        [$table, $uid, $field] = $pParams;
6208
        // If references are set for this field, set flag so they can be corrected later:
6209
        if ($this->isReferenceField($dsConf) && (string)$dataValue !== '') {
6210
            $vArray = $this->remapListedDBRecords_procDBRefs($dsConf, $dataValue, $uid, $table);
6211
            if (is_array($vArray)) {
6212
                $dataValue = implode(',', $vArray);
6213
            }
6214
        }
6215
        // Return
6216
        return ['value' => $dataValue];
6217
    }
6218
6219
    /**
6220
     * Performs remapping of old UID values to NEW uid values for a DB reference field.
6221
     *
6222
     * @param array $conf TCA field config
6223
     * @param string $value Field value
6224
     * @param int $MM_localUid UID of local record (for MM relations - might need to change if support for FlexForms should be done!)
6225
     * @param string $table Table name
6226
     * @return array|null Returns array of items ready to implode for field content.
6227
     * @see remapListedDBRecords()
6228
     * @internal should only be used from within DataHandler
6229
     */
6230
    public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table)
6231
    {
6232
        // Initialize variables
6233
        // Will be set TRUE if an upgrade should be done...
6234
        $set = false;
6235
        // Allowed tables for references.
6236
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
6237
        // Table name to prepend the UID
6238
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
6239
        // Which tables that should possibly not be remapped
6240
        $dontRemapTables = GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true);
6241
        // Convert value to list of references:
6242
        $dbAnalysis = $this->createRelationHandlerInstance();
6243
        $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false);
6244
        $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf);
6245
        // Traverse those references and map IDs:
6246
        foreach ($dbAnalysis->itemArray as $k => $v) {
6247
            $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0;
6248
            if ($mapID && !in_array($v['table'], $dontRemapTables, true)) {
6249
                $dbAnalysis->itemArray[$k]['id'] = $mapID;
6250
                $set = true;
6251
            }
6252
        }
6253
        if (!empty($conf['MM'])) {
6254
            // Purge invalid items (live/version)
6255
            $dbAnalysis->purgeItemArray();
6256
            if ($dbAnalysis->isPurged()) {
6257
                $set = true;
6258
            }
6259
6260
            // If record has been versioned/copied in this process, handle invalid relations of the live record
6261
            $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid);
6262
            $originalId = 0;
6263
            if (!empty($this->copyMappingArray_merged[$table])) {
6264
                $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]);
6265
            }
6266
            if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) {
6267
                $liveRelations = $this->createRelationHandlerInstance();
6268
                $liveRelations->setWorkspaceId(0);
6269
                $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf);
6270
                // Purge invalid relations in the live workspace ("0")
6271
                $liveRelations->purgeItemArray(0);
6272
                if ($liveRelations->isPurged()) {
6273
                    $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

6273
                    $liveRelations->writeMM($conf['MM'], $liveId, /** @scrutinizer ignore-type */ $prependName);
Loading history...
6274
                }
6275
            }
6276
        }
6277
        // If a change has been done, set the new value(s)
6278
        if ($set) {
6279
            if ($conf['MM'] ?? false) {
6280
                $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName);
6281
            } else {
6282
                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

6282
                return $dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prependName);
Loading history...
6283
            }
6284
        }
6285
        return null;
6286
    }
6287
6288
    /**
6289
     * Performs remapping of old UID values to NEW uid values for an inline field.
6290
     *
6291
     * @param array $conf TCA field config
6292
     * @param string $value Field value
6293
     * @param int $uid The uid of the ORIGINAL record
6294
     * @param string $table Table name
6295
     * @internal should only be used from within DataHandler
6296
     */
6297
    public function remapListedDBRecords_procInline($conf, $value, $uid, $table)
6298
    {
6299
        $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
6300
        if ($conf['foreign_table']) {
6301
            $inlineType = $this->getInlineFieldType($conf);
6302
            if ($inlineType === 'mm') {
6303
                $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
6304
            } elseif ($inlineType !== false) {
6305
                /** @var RelationHandler $dbAnalysis */
6306
                $dbAnalysis = $this->createRelationHandlerInstance();
6307
                $dbAnalysis->start($value, $conf['foreign_table'], '', 0, $table, $conf);
6308
6309
                $updatePidForRecords = [];
6310
                // Update values for specific versioned records
6311
                foreach ($dbAnalysis->itemArray as &$item) {
6312
                    $updatePidForRecords[$item['table']][] = $item['id'];
6313
                    $versionedId = $this->getAutoVersionId($item['table'], $item['id']);
6314
                    if ($versionedId !== null) {
6315
                        $updatePidForRecords[$item['table']][] = $versionedId;
6316
                        $item['id'] = $versionedId;
6317
                    }
6318
                }
6319
6320
                // Update child records if using pointer fields ('foreign_field'):
6321
                if ($inlineType === 'field') {
6322
                    $dbAnalysis->writeForeignField($conf, $uid, $theUidToUpdate);
0 ignored issues
show
Deprecated Code introduced by
The function TYPO3\CMS\Core\Database\...er::writeForeignField() has been deprecated. ( Ignorable by Annotation )

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

6322
                    /** @scrutinizer ignore-deprecated */ $dbAnalysis->writeForeignField($conf, $uid, $theUidToUpdate);
Loading history...
6323
                }
6324
                $thePidToUpdate = null;
6325
                // If the current field is set on a page record, update the pid of related child records:
6326
                if ($table === 'pages') {
6327
                    $thePidToUpdate = $theUidToUpdate;
6328
                } elseif (isset($this->registerDBPids[$table][$uid])) {
6329
                    $thePidToUpdate = $this->registerDBPids[$table][$uid];
6330
                    $thePidToUpdate = $this->copyMappingArray_merged['pages'][$thePidToUpdate];
6331
                }
6332
6333
                // Update child records if change to pid is required
6334
                if ($thePidToUpdate && !empty($updatePidForRecords)) {
6335
                    // Ensure that only the default language page is used as PID
6336
                    $thePidToUpdate = $this->getDefaultLanguagePageId($thePidToUpdate);
6337
                    // @todo: this can probably go away
6338
                    // ensure, only live page ids are used as 'pid' values
6339
                    $liveId = BackendUtility::getLiveVersionIdOfRecord('pages', $theUidToUpdate);
6340
                    if ($liveId !== null) {
6341
                        $thePidToUpdate = $liveId;
6342
                    }
6343
                    $updateValues = ['pid' => $thePidToUpdate];
6344
                    foreach ($updatePidForRecords as $tableName => $uids) {
6345
                        if (empty($tableName) || empty($uids)) {
6346
                            continue;
6347
                        }
6348
                        $conn = GeneralUtility::makeInstance(ConnectionPool::class)
6349
                            ->getConnectionForTable($tableName);
6350
                        foreach ($uids as $updateUid) {
6351
                            $conn->update($tableName, $updateValues, ['uid' => $updateUid]);
6352
                        }
6353
                    }
6354
                }
6355
            }
6356
        }
6357
    }
6358
6359
    /**
6360
     * Processes the $this->remapStack at the end of copying, inserting, etc. actions.
6361
     * The remapStack takes care about the correct mapping of new and old uids in case of relational data.
6362
     * @internal should only be used from within DataHandler
6363
     */
6364
    public function processRemapStack()
6365
    {
6366
        // Processes the remap stack:
6367
        if (is_array($this->remapStack)) {
0 ignored issues
show
introduced by
The condition is_array($this->remapStack) is always true.
Loading history...
6368
            $remapFlexForms = [];
6369
            $hookPayload = [];
6370
6371
            $newValue = null;
6372
            foreach ($this->remapStack as $remapAction) {
6373
                // If no position index for the arguments was set, skip this remap action:
6374
                if (!is_array($remapAction['pos'])) {
6375
                    continue;
6376
                }
6377
                // Load values from the argument array in remapAction:
6378
                $field = $remapAction['field'];
6379
                $id = $remapAction['args'][$remapAction['pos']['id']];
6380
                $rawId = $id;
6381
                $table = $remapAction['args'][$remapAction['pos']['table']];
6382
                $valueArray = $remapAction['args'][$remapAction['pos']['valueArray']];
6383
                $tcaFieldConf = $remapAction['args'][$remapAction['pos']['tcaFieldConf']];
6384
                $additionalData = $remapAction['additionalData'] ?? [];
6385
                // The record is new and has one or more new ids (in case of versioning/workspaces):
6386
                if (str_contains($id, 'NEW')) {
6387
                    // Replace NEW...-ID with real uid:
6388
                    $id = $this->substNEWwithIDs[$id] ?? '';
6389
                    // If the new parent record is on a non-live workspace or versionized, it has another new id:
6390
                    if (isset($this->autoVersionIdMap[$table][$id])) {
6391
                        $id = $this->autoVersionIdMap[$table][$id];
6392
                    }
6393
                    $remapAction['args'][$remapAction['pos']['id']] = $id;
6394
                }
6395
                // Replace relations to NEW...-IDs in field value (uids of child records):
6396
                if (is_array($valueArray)) {
6397
                    foreach ($valueArray as $key => $value) {
6398
                        if (str_contains($value, 'NEW')) {
6399
                            if (!str_contains($value, '_')) {
6400
                                $affectedTable = $tcaFieldConf['foreign_table'] ?? '';
6401
                                $prependTable = false;
6402
                            } else {
6403
                                $parts = explode('_', $value);
6404
                                $value = array_pop($parts);
6405
                                $affectedTable = implode('_', $parts);
6406
                                $prependTable = true;
6407
                            }
6408
                            $value = $this->substNEWwithIDs[$value] ?? '';
6409
                            // The record is new, but was also auto-versionized and has another new id:
6410
                            if (isset($this->autoVersionIdMap[$affectedTable][$value])) {
6411
                                $value = $this->autoVersionIdMap[$affectedTable][$value];
6412
                            }
6413
                            if ($prependTable) {
6414
                                $value = $affectedTable . '_' . $value;
6415
                            }
6416
                            // Set a hint that this was a new child record:
6417
                            $this->newRelatedIDs[$affectedTable][] = $value;
6418
                            $valueArray[$key] = $value;
6419
                        }
6420
                    }
6421
                    $remapAction['args'][$remapAction['pos']['valueArray']] = $valueArray;
6422
                }
6423
                // Process the arguments with the defined function:
6424
                if (!empty($remapAction['func'])) {
6425
                    $callable = [$this, $remapAction['func']];
6426
                    if (is_callable($callable)) {
6427
                        $newValue = $callable(...$remapAction['args']);
6428
                    }
6429
                }
6430
                // If array is returned, check for maxitems condition, if string is returned this was already done:
6431
                if (is_array($newValue)) {
6432
                    $newValue = implode(',', $this->checkValue_checkMax($tcaFieldConf, $newValue));
6433
                    // The reference casting is only required if
6434
                    // checkValue_group_select_processDBdata() returns an array
6435
                    $newValue = $this->castReferenceValue($newValue, $tcaFieldConf);
6436
                }
6437
                // Update in database (list of children (csv) or number of relations (foreign_field)):
6438
                if (!empty($field)) {
6439
                    $fieldArray = [$field => $newValue];
6440
                    if ($GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) {
6441
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
6442
                    }
6443
                    $this->updateDB($table, $id, $fieldArray);
6444
                } elseif (!empty($additionalData['flexFormId']) && !empty($additionalData['flexFormPath'])) {
6445
                    // Collect data to update FlexForms
6446
                    $flexFormId = $additionalData['flexFormId'];
6447
                    $flexFormPath = $additionalData['flexFormPath'];
6448
6449
                    if (!isset($remapFlexForms[$flexFormId])) {
6450
                        $remapFlexForms[$flexFormId] = [];
6451
                    }
6452
6453
                    $remapFlexForms[$flexFormId][$flexFormPath] = $newValue;
6454
                }
6455
6456
                // Collect elements that shall trigger processDatamap_afterDatabaseOperations
6457
                if (isset($this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'])) {
6458
                    $hookArgs = $this->remapStackRecords[$table][$rawId]['processDatamap_afterDatabaseOperations'];
6459
                    if (!isset($hookPayload[$table][$rawId])) {
6460
                        $hookPayload[$table][$rawId] = [
6461
                            'status' => $hookArgs['status'],
6462
                            'fieldArray' => $hookArgs['fieldArray'],
6463
                            'hookObjects' => $hookArgs['hookObjectsArr'],
6464
                        ];
6465
                    }
6466
                    $hookPayload[$table][$rawId]['fieldArray'][$field] = $newValue;
6467
                }
6468
            }
6469
6470
            if ($remapFlexForms) {
6471
                foreach ($remapFlexForms as $flexFormId => $modifications) {
6472
                    $this->updateFlexFormData((string)$flexFormId, $modifications);
6473
                }
6474
            }
6475
6476
            foreach ($hookPayload as $tableName => $rawIdPayload) {
6477
                foreach ($rawIdPayload as $rawId => $payload) {
6478
                    foreach ($payload['hookObjects'] as $hookObject) {
6479
                        if (!method_exists($hookObject, 'processDatamap_afterDatabaseOperations')) {
6480
                            continue;
6481
                        }
6482
                        $hookObject->processDatamap_afterDatabaseOperations(
6483
                            $payload['status'],
6484
                            $tableName,
6485
                            $rawId,
6486
                            $payload['fieldArray'],
6487
                            $this
6488
                        );
6489
                    }
6490
                }
6491
            }
6492
        }
6493
        // Processes the remap stack actions:
6494
        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...
6495
            foreach ($this->remapStackActions as $action) {
6496
                if (isset($action['callback'], $action['arguments'])) {
6497
                    $action['callback'](...$action['arguments']);
6498
                }
6499
            }
6500
        }
6501
        // Reset:
6502
        $this->remapStack = [];
6503
        $this->remapStackRecords = [];
6504
        $this->remapStackActions = [];
6505
    }
6506
6507
    /**
6508
     * Updates FlexForm data.
6509
     *
6510
     * @param string $flexFormId e.g. <table>:<uid>:<field>
6511
     * @param array $modifications Modifications with paths and values (e.g. 'sDEF/lDEV/field/vDEF' => 'TYPO3')
6512
     */
6513
    protected function updateFlexFormData($flexFormId, array $modifications)
6514
    {
6515
        [$table, $uid, $field] = explode(':', $flexFormId, 3);
6516
6517
        if (!MathUtility::canBeInterpretedAsInteger($uid) && !empty($this->substNEWwithIDs[$uid])) {
6518
            $uid = $this->substNEWwithIDs[$uid];
6519
        }
6520
6521
        $record = $this->recordInfo($table, $uid, '*');
6522
6523
        if (!$table || !$uid || !$field || !is_array($record)) {
6524
            return;
6525
        }
6526
6527
        BackendUtility::workspaceOL($table, $record);
6528
6529
        // Get current data structure and value array:
6530
        $valueStructure = GeneralUtility::xml2array($record[$field]);
6531
6532
        // Do recursive processing of the XML data:
6533
        foreach ($modifications as $path => $value) {
6534
            $valueStructure['data'] = ArrayUtility::setValueByPath(
6535
                $valueStructure['data'],
6536
                $path,
6537
                $value
6538
            );
6539
        }
6540
6541
        if (is_array($valueStructure['data'])) {
6542
            // The return value should be compiled back into XML
6543
            $values = [
6544
                $field => $this->checkValue_flexArray2Xml($valueStructure, true),
6545
            ];
6546
6547
            $this->updateDB($table, $uid, $values);
6548
        }
6549
    }
6550
6551
    /**
6552
     * Adds an instruction to the remap action stack (used with IRRE).
6553
     *
6554
     * @param string $table The affected table
6555
     * @param int|string $id The affected ID
6556
     * @param callable $callback The callback information (object and method)
6557
     * @param array $arguments The arguments to be used with the callback
6558
     * @internal should only be used from within DataHandler
6559
     */
6560
    public function addRemapAction($table, $id, callable $callback, array $arguments)
6561
    {
6562
        $this->remapStackActions[] = [
6563
            'affects' => [
6564
                'table' => $table,
6565
                'id' => $id,
6566
            ],
6567
            'callback' => $callback,
6568
            'arguments' => $arguments,
6569
        ];
6570
    }
6571
6572
    /**
6573
     * If a parent record was versionized on a workspace in $this->process_datamap,
6574
     * it might be possible, that child records (e.g. on using IRRE) were affected.
6575
     * This function finds these relations and updates their uids in the $incomingFieldArray.
6576
     * The $incomingFieldArray is updated by reference!
6577
     *
6578
     * @param string $table Table name of the parent record
6579
     * @param int $id Uid of the parent record
6580
     * @param array $incomingFieldArray Reference to the incomingFieldArray of process_datamap
6581
     * @param array $registerDBList Reference to the $registerDBList array that was created/updated by versionizing calls to DataHandler in process_datamap.
6582
     * @internal should only be used from within DataHandler
6583
     */
6584
    public function getVersionizedIncomingFieldArray($table, $id, &$incomingFieldArray, &$registerDBList): void
6585
    {
6586
        if (!isset($registerDBList[$table][$id]) || !is_array($registerDBList[$table][$id])) {
6587
            return;
6588
        }
6589
        foreach ($incomingFieldArray as $field => $value) {
6590
            $foreignTable = $GLOBALS['TCA'][$table]['columns'][$field]['config']['foreign_table'] ?? '';
6591
            if (($registerDBList[$table][$id][$field] ?? false)
6592
                && !empty($foreignTable)
6593
            ) {
6594
                $newValueArray = [];
6595
                $origValueArray = is_array($value) ? $value : explode(',', $value);
6596
                // Update the uids of the copied records, but also take care about new records:
6597
                foreach ($origValueArray as $childId) {
6598
                    $newValueArray[] = $this->autoVersionIdMap[$foreignTable][$childId] ?? $childId;
6599
                }
6600
                // Set the changed value to the $incomingFieldArray
6601
                $incomingFieldArray[$field] = implode(',', $newValueArray);
6602
            }
6603
        }
6604
        // Clean up the $registerDBList array:
6605
        unset($registerDBList[$table][$id]);
6606
        if (empty($registerDBList[$table])) {
6607
            unset($registerDBList[$table]);
6608
        }
6609
    }
6610
6611
    /**
6612
     * Simple helper method to hard delete one row from table ignoring delete TCA field
6613
     *
6614
     * @param string $table A row from this table should be deleted
6615
     * @param int $uid Uid of row to be deleted
6616
     */
6617
    protected function hardDeleteSingleRecord(string $table, int $uid): void
6618
    {
6619
        GeneralUtility::makeInstance(ConnectionPool::class)
6620
            ->getConnectionForTable($table)
6621
            ->delete($table, ['uid' => $uid], [\PDO::PARAM_INT]);
6622
    }
6623
6624
    /*****************************
6625
     *
6626
     * Access control / Checking functions
6627
     *
6628
     *****************************/
6629
    /**
6630
     * Checking group modify_table access list
6631
     *
6632
     * @param string $table Table name
6633
     * @return bool Returns TRUE if the user has general access to modify the $table
6634
     * @internal should only be used from within DataHandler
6635
     */
6636
    public function checkModifyAccessList($table)
6637
    {
6638
        $res = $this->admin || (!$this->tableAdminOnly($table) && isset($this->BE_USER->groupData['tables_modify']) && GeneralUtility::inList($this->BE_USER->groupData['tables_modify'], $table));
6639
        // Hook 'checkModifyAccessList': Post-processing of the state of access
6640
        foreach ($this->getCheckModifyAccessListHookObjects() as $hookObject) {
6641
            /** @var DataHandlerCheckModifyAccessListHookInterface $hookObject */
6642
            $hookObject->checkModifyAccessList($res, $table, $this);
6643
        }
6644
        return $res;
6645
    }
6646
6647
    /**
6648
     * Checking if a record with uid $id from $table is in the BE_USERS webmounts which is required for editing etc.
6649
     *
6650
     * @param string $table Table name
6651
     * @param int $id UID of record
6652
     * @return bool Returns TRUE if OK. Cached results.
6653
     * @internal should only be used from within DataHandler
6654
     */
6655
    public function isRecordInWebMount($table, $id)
6656
    {
6657
        if (!isset($this->isRecordInWebMount_Cache[$table . ':' . $id])) {
6658
            $recP = $this->getRecordProperties($table, $id);
6659
            $this->isRecordInWebMount_Cache[$table . ':' . $id] = $this->isInWebMount($recP['event_pid']);
6660
        }
6661
        return $this->isRecordInWebMount_Cache[$table . ':' . $id];
6662
    }
6663
6664
    /**
6665
     * Checks if the input page ID is in the BE_USER webmounts
6666
     *
6667
     * @param int $pid Page ID to check
6668
     * @return bool TRUE if OK. Cached results.
6669
     * @internal should only be used from within DataHandler
6670
     */
6671
    public function isInWebMount($pid)
6672
    {
6673
        if (!isset($this->isInWebMount_Cache[$pid])) {
6674
            $this->isInWebMount_Cache[$pid] = $this->BE_USER->isInWebMount($pid);
0 ignored issues
show
Deprecated Code introduced by
The function TYPO3\CMS\Core\Authentic...ication::isInWebMount() has been deprecated. ( Ignorable by Annotation )

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

6674
            $this->isInWebMount_Cache[$pid] = /** @scrutinizer ignore-deprecated */ $this->BE_USER->isInWebMount($pid);
Loading history...
6675
        }
6676
        return $this->isInWebMount_Cache[$pid];
6677
    }
6678
6679
    /**
6680
     * Checks if user may update a record with uid=$id from $table
6681
     *
6682
     * @param string $table Record table
6683
     * @param int $id Record UID
6684
     * @param array|bool $data Record data
6685
     * @param array $hookObjectsArr Hook objects
6686
     * @return bool Returns TRUE if the user may update the record given by $table and $id
6687
     * @internal should only be used from within DataHandler
6688
     */
6689
    public function checkRecordUpdateAccess($table, $id, $data = false, $hookObjectsArr = null)
6690
    {
6691
        $res = null;
6692
        if (is_array($hookObjectsArr)) {
6693
            foreach ($hookObjectsArr as $hookObj) {
6694
                if (method_exists($hookObj, 'checkRecordUpdateAccess')) {
6695
                    $res = $hookObj->checkRecordUpdateAccess($table, $id, $data, $res, $this);
6696
                }
6697
            }
6698
            if (isset($res)) {
6699
                return (bool)$res;
6700
            }
6701
        }
6702
        $res = false;
6703
6704
        if ($GLOBALS['TCA'][$table] && (int)$id > 0) {
6705
            $cacheId = 'checkRecordUpdateAccess_' . $table . '_' . $id;
6706
6707
            // If information is cached, return it
6708
            $cachedValue = $this->runtimeCache->get($cacheId);
6709
            if (!empty($cachedValue)) {
6710
                return $cachedValue;
6711
            }
6712
6713
            if ($table === 'pages' || ($table === 'sys_file_reference' && array_key_exists('pages', $this->datamap))) {
6714
                // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
6715
                $perms = Permission::PAGE_EDIT;
6716
            } else {
6717
                $perms = Permission::CONTENT_EDIT;
6718
            }
6719
            if ($this->doesRecordExist($table, $id, $perms)) {
6720
                $res = 1;
6721
            }
6722
            // Cache the result
6723
            $this->runtimeCache->set($cacheId, $res);
6724
        }
6725
        return $res;
6726
    }
6727
6728
    /**
6729
     * Checks if user may insert a record from $insertTable on $pid
6730
     *
6731
     * @param string $insertTable Tablename to check
6732
     * @param int $pid Integer PID
6733
     * @param int $action For logging: Action number.
6734
     * @return bool Returns TRUE if the user may insert a record from table $insertTable on page $pid
6735
     * @internal should only be used from within DataHandler
6736
     */
6737
    public function checkRecordInsertAccess($insertTable, $pid, $action = SystemLogDatabaseAction::INSERT)
6738
    {
6739
        $pid = (int)$pid;
6740
        if ($pid < 0) {
6741
            return false;
6742
        }
6743
        // If information is cached, return it
6744
        if (isset($this->recInsertAccessCache[$insertTable][$pid])) {
6745
            return $this->recInsertAccessCache[$insertTable][$pid];
6746
        }
6747
6748
        $res = false;
6749
        if ($insertTable === 'pages') {
6750
            $perms = Permission::PAGE_NEW;
6751
        } elseif (($insertTable === 'sys_file_reference') && array_key_exists('pages', $this->datamap)) {
6752
            // @todo: find a more generic way to handle content relations of a page (without needing content editing access to that page)
6753
            $perms = Permission::PAGE_EDIT;
6754
        } else {
6755
            $perms = Permission::CONTENT_EDIT;
6756
        }
6757
        $pageExists = (bool)$this->doesRecordExist('pages', $pid, $perms);
6758
        // 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
6759
        if ($pageExists || $pid === 0 && ($this->admin || BackendUtility::isRootLevelRestrictionIgnored($insertTable))) {
6760
            // Check permissions
6761
            if ($this->isTableAllowedForThisPage($pid, $insertTable)) {
6762
                $res = true;
6763
                // Cache the result
6764
                $this->recInsertAccessCache[$insertTable][$pid] = $res;
6765
            } elseif ($this->enableLogging) {
6766
                $propArr = $this->getRecordProperties('pages', $pid);
6767
                $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']);
6768
            }
6769
        } elseif ($this->enableLogging) {
6770
            $propArr = $this->getRecordProperties('pages', $pid);
6771
            $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']);
6772
        }
6773
        return $res;
6774
    }
6775
6776
    /**
6777
     * 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.
6778
     *
6779
     * @param int $page_uid Page id for which to check, including 0 (zero) if checking for page tree root.
6780
     * @param string $checkTable Table name to check
6781
     * @return bool TRUE if OK
6782
     * @internal should only be used from within DataHandler
6783
     */
6784
    public function isTableAllowedForThisPage($page_uid, $checkTable)
6785
    {
6786
        $page_uid = (int)$page_uid;
6787
        $rootLevelSetting = (int)($GLOBALS['TCA'][$checkTable]['ctrl']['rootLevel'] ?? 0);
6788
        // 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.
6789
        if ($checkTable !== 'pages' && $rootLevelSetting !== -1 && ($rootLevelSetting xor !$page_uid)) {
6790
            return false;
6791
        }
6792
        $allowed = false;
6793
        // Check root-level
6794
        if (!$page_uid) {
6795
            if ($this->admin || BackendUtility::isRootLevelRestrictionIgnored($checkTable)) {
6796
                $allowed = true;
6797
            }
6798
        } else {
6799
            // Check non-root-level
6800
            $doktype = $this->pageInfo($page_uid, 'doktype');
6801
            $allowedTableList = $GLOBALS['PAGES_TYPES'][$doktype]['allowedTables'] ?? $GLOBALS['PAGES_TYPES']['default']['allowedTables'];
6802
            $allowedArray = GeneralUtility::trimExplode(',', $allowedTableList, true);
6803
            // If all tables or the table is listed as an allowed type, return TRUE
6804
            if (str_contains($allowedTableList, '*') || in_array($checkTable, $allowedArray, true)) {
6805
                $allowed = true;
6806
            }
6807
        }
6808
        return $allowed;
6809
    }
6810
6811
    /**
6812
     * Checks if record can be selected based on given permission criteria
6813
     *
6814
     * @param string $table Record table name
6815
     * @param int $id Record UID
6816
     * @param int $perms Permission restrictions to observe: integer that will be bitwise AND'ed.
6817
     * @return bool Returns TRUE if the record given by $table, $id and $perms can be selected
6818
     *
6819
     * @throws \RuntimeException
6820
     * @internal should only be used from within DataHandler
6821
     */
6822
    public function doesRecordExist($table, $id, int $perms)
6823
    {
6824
        return $this->recordInfoWithPermissionCheck($table, $id, $perms, 'uid, pid') !== false;
6825
    }
6826
6827
    /**
6828
     * Looks up a page based on permissions.
6829
     *
6830
     * @param int $id Page id
6831
     * @param int $perms Permission integer
6832
     * @param array $columns Columns to select
6833
     * @return bool|array
6834
     * @internal
6835
     * @see doesRecordExist()
6836
     */
6837
    protected function doesRecordExist_pageLookUp($id, $perms, $columns = ['uid'])
6838
    {
6839
        $permission = new Permission($perms);
6840
        $cacheId = md5('doesRecordExist_pageLookUp_' . $id . '_' . $perms . '_' . implode(
6841
            '_',
6842
            $columns
6843
        ) . '_' . (string)$this->admin);
6844
6845
        // If result is cached, return it
6846
        $cachedResult = $this->runtimeCache->get($cacheId);
6847
        if (!empty($cachedResult)) {
6848
            return $cachedResult;
6849
        }
6850
6851
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6852
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6853
        $queryBuilder
6854
            ->select(...$columns)
6855
            ->from('pages')
6856
            ->where($queryBuilder->expr()->eq(
6857
                'uid',
6858
                $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)
6859
            ));
6860
        if (!$permission->nothingIsGranted() && !$this->admin) {
6861
            $queryBuilder->andWhere($this->BE_USER->getPagePermsClause($perms));
6862
        }
6863
        if (!$this->admin && $GLOBALS['TCA']['pages']['ctrl']['editlock'] &&
6864
            ($permission->editPagePermissionIsGranted() || $permission->deletePagePermissionIsGranted() || $permission->editContentPermissionIsGranted())
6865
        ) {
6866
            $queryBuilder->andWhere($queryBuilder->expr()->eq(
6867
                $GLOBALS['TCA']['pages']['ctrl']['editlock'],
6868
                $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
6869
            ));
6870
        }
6871
6872
        $row = $queryBuilder->execute()->fetchAssociative();
6873
        $this->runtimeCache->set($cacheId, $row);
6874
6875
        return $row;
6876
    }
6877
6878
    /**
6879
     * Checks if a whole branch of pages exists
6880
     *
6881
     * Tests the branch under $pid like doesRecordExist(), but it doesn't test the page with $pid as uid - use doesRecordExist() for this purpose.
6882
     * 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
6883
     *
6884
     * @param string $inList List of page uids, this is added to and returned in the end
6885
     * @param int $pid Page ID to select subpages from.
6886
     * @param int $perms Perms integer to check each page record for.
6887
     * @param bool $recurse Recursion flag: If set, it will go out through the branch.
6888
     * @return string|int List of page IDs in branch, if there are subpages, empty string if there are none or -1 if no permission
6889
     * @internal should only be used from within DataHandler
6890
     */
6891
    public function doesBranchExist($inList, $pid, $perms, $recurse)
6892
    {
6893
        $pid = (int)$pid;
6894
        $perms = (int)$perms;
6895
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6896
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6897
        $result = $queryBuilder
6898
            ->select('uid', 'perms_userid', 'perms_groupid', 'perms_user', 'perms_group', 'perms_everybody')
6899
            ->from('pages')
6900
            ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)))
6901
            ->orderBy('sorting')
6902
            ->execute();
6903
        while ($row = $result->fetchAssociative()) {
6904
            // IF admin, then it's OK
6905
            if ($this->admin || $this->BE_USER->doesUserHaveAccess($row, $perms)) {
6906
                $inList .= $row['uid'] . ',';
6907
                if ($recurse) {
6908
                    // Follow the subpages recursively...
6909
                    $inList = $this->doesBranchExist($inList, $row['uid'], $perms, $recurse);
6910
                    if ($inList === -1) {
6911
                        return -1;
6912
                    }
6913
                }
6914
            } else {
6915
                // No permissions
6916
                return -1;
6917
            }
6918
        }
6919
        return $inList;
6920
    }
6921
6922
    /**
6923
     * Checks if the $table is readOnly
6924
     *
6925
     * @param string $table Table name
6926
     * @return bool TRUE, if readonly
6927
     * @internal should only be used from within DataHandler
6928
     */
6929
    public function tableReadOnly($table)
6930
    {
6931
        // Returns TRUE if table is readonly
6932
        return (bool)($GLOBALS['TCA'][$table]['ctrl']['readOnly'] ?? false);
6933
    }
6934
6935
    /**
6936
     * Checks if the $table is only editable by admin-users
6937
     *
6938
     * @param string $table Table name
6939
     * @return bool TRUE, if readonly
6940
     * @internal should only be used from within DataHandler
6941
     */
6942
    public function tableAdminOnly($table)
6943
    {
6944
        // Returns TRUE if table is admin-only
6945
        return !empty($GLOBALS['TCA'][$table]['ctrl']['adminOnly']);
6946
    }
6947
6948
    /**
6949
     * Checks if page $id is a uid in the rootline of page id $destinationId
6950
     * Used when moving a page
6951
     *
6952
     * @param int $destinationId Destination Page ID to test
6953
     * @param int $id Page ID to test for presence inside Destination
6954
     * @return bool Returns FALSE if ID is inside destination (including equal to)
6955
     * @internal should only be used from within DataHandler
6956
     */
6957
    public function destNotInsideSelf($destinationId, $id)
6958
    {
6959
        $loopCheck = 100;
6960
        $destinationId = (int)$destinationId;
6961
        $id = (int)$id;
6962
        if ($destinationId === $id) {
6963
            return false;
6964
        }
6965
        while ($destinationId !== 0 && $loopCheck > 0) {
6966
            $loopCheck--;
6967
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
6968
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
6969
            $result = $queryBuilder
6970
                ->select('pid', 'uid', 't3ver_oid', 't3ver_wsid')
6971
                ->from('pages')
6972
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($destinationId, \PDO::PARAM_INT)))
6973
                ->execute();
6974
            if ($row = $result->fetchAssociative()) {
6975
                // Ensure that the moved location is used as the PID value
6976
                BackendUtility::workspaceOL('pages', $row, $this->BE_USER->workspace);
6977
                if ($row['pid'] == $id) {
6978
                    return false;
6979
                }
6980
                $destinationId = (int)$row['pid'];
6981
            } else {
6982
                return false;
6983
            }
6984
        }
6985
        return true;
6986
    }
6987
6988
    /**
6989
     * 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
6990
     * Will also generate this list for admin-users so they must be check for before calling the function
6991
     *
6992
     * @return array Array of [table]-[field] pairs to exclude from editing.
6993
     * @internal should only be used from within DataHandler
6994
     */
6995
    public function getExcludeListArray()
6996
    {
6997
        $list = [];
6998
        if (isset($this->BE_USER->groupData['non_exclude_fields'])) {
6999
            $nonExcludeFieldsArray = array_flip(GeneralUtility::trimExplode(',', $this->BE_USER->groupData['non_exclude_fields']));
7000
            foreach ($GLOBALS['TCA'] as $table => $tableConfiguration) {
7001
                if (isset($tableConfiguration['columns'])) {
7002
                    foreach ($tableConfiguration['columns'] as $field => $config) {
7003
                        $isExcludeField = ($config['exclude'] ?? false);
7004
                        $isOnlyVisibleForAdmins = ($GLOBALS['TCA'][$table]['columns'][$field]['displayCond'] ?? '') === 'HIDE_FOR_NON_ADMINS';
7005
                        $editorHasPermissionForThisField = isset($nonExcludeFieldsArray[$table . ':' . $field]);
7006
                        if ($isOnlyVisibleForAdmins || ($isExcludeField && !$editorHasPermissionForThisField)) {
7007
                            $list[] = $table . '-' . $field;
7008
                        }
7009
                    }
7010
                }
7011
            }
7012
        }
7013
7014
        return $list;
7015
    }
7016
7017
    /**
7018
     * Checks if there are records on a page from tables that are not allowed
7019
     *
7020
     * @param int|string $page_uid Page ID
7021
     * @param int $doktype Page doktype
7022
     * @return bool|array Returns a list of the tables that are 'present' on the page but not allowed with the page_uid/doktype
7023
     * @internal should only be used from within DataHandler
7024
     */
7025
    public function doesPageHaveUnallowedTables($page_uid, $doktype)
7026
    {
7027
        $page_uid = (int)$page_uid;
7028
        if (!$page_uid) {
7029
            // Not a number. Probably a new page
7030
            return false;
7031
        }
7032
        $allowedTableList = $GLOBALS['PAGES_TYPES'][$doktype]['allowedTables'] ?? $GLOBALS['PAGES_TYPES']['default']['allowedTables'];
7033
        // If all tables are allowed, return early
7034
        if (str_contains($allowedTableList, '*')) {
7035
            return false;
7036
        }
7037
        $allowedArray = GeneralUtility::trimExplode(',', $allowedTableList, true);
7038
        $tableList = [];
7039
        $allTableNames = $this->compileAdminTables();
7040
        foreach ($allTableNames as $table) {
7041
            // If the table is not in the allowed list, check if there are records...
7042
            if (in_array($table, $allowedArray, true)) {
7043
                continue;
7044
            }
7045
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7046
            $queryBuilder->getRestrictions()->removeAll();
7047
            $count = $queryBuilder
7048
                ->count('uid')
7049
                ->from($table)
7050
                ->where($queryBuilder->expr()->eq(
7051
                    'pid',
7052
                    $queryBuilder->createNamedParameter($page_uid, \PDO::PARAM_INT)
7053
                ))
7054
                ->execute()
7055
                ->fetchOne();
7056
            if ($count) {
7057
                $tableList[] = $table;
7058
            }
7059
        }
7060
        return implode(',', $tableList);
7061
    }
7062
7063
    /*****************************
7064
     *
7065
     * Information lookup
7066
     *
7067
     *****************************/
7068
    /**
7069
     * Returns the value of the $field from page $id
7070
     * 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!
7071
     *
7072
     * @param int $id Page uid
7073
     * @param string $field Field name for which to return value
7074
     * @return string Value of the field. Result is cached in $this->pageCache[$id][$field] and returned from there next time!
7075
     * @internal should only be used from within DataHandler
7076
     */
7077
    public function pageInfo($id, $field)
7078
    {
7079
        if (!isset($this->pageCache[$id])) {
7080
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
7081
            $queryBuilder->getRestrictions()->removeAll();
7082
            $row = $queryBuilder
7083
                ->select('*')
7084
                ->from('pages')
7085
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7086
                ->execute()
7087
                ->fetchAssociative();
7088
            if ($row) {
7089
                $this->pageCache[$id] = $row;
7090
            }
7091
        }
7092
        return $this->pageCache[$id][$field];
7093
    }
7094
7095
    /**
7096
     * Returns the row of a record given by $table and $id and $fieldList (list of fields, may be '*')
7097
     * NOTICE: No check for deleted or access!
7098
     *
7099
     * @param string $table Table name
7100
     * @param int $id UID of the record from $table
7101
     * @param string $fieldList Field list for the SELECT query, eg. "*" or "uid,pid,...
7102
     * @return array|null Returns the selected record on success, otherwise NULL.
7103
     * @internal should only be used from within DataHandler
7104
     */
7105
    public function recordInfo($table, $id, $fieldList)
7106
    {
7107
        // Skip, if searching for NEW records or there's no TCA table definition
7108
        if ((int)$id === 0 || !isset($GLOBALS['TCA'][$table])) {
7109
            return null;
7110
        }
7111
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7112
        $queryBuilder->getRestrictions()->removeAll();
7113
        $result = $queryBuilder
7114
            ->select(...GeneralUtility::trimExplode(',', $fieldList))
7115
            ->from($table)
7116
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7117
            ->execute()
7118
            ->fetchAssociative();
7119
        return $result ?: null;
7120
    }
7121
7122
    /**
7123
     * Checks if record exists with and without permission check and returns that row
7124
     *
7125
     * @param string $table Record table name
7126
     * @param int $id Record UID
7127
     * @param int $perms Permission restrictions to observe: An integer that will be bitwise AND'ed.
7128
     * @param string $fieldList - fields - default is '*'
7129
     * @throws \RuntimeException
7130
     * @return array<string,mixed>|false Row if exists and accessible, false otherwise
7131
     */
7132
    protected function recordInfoWithPermissionCheck(string $table, int $id, int $perms, string $fieldList = '*')
7133
    {
7134
        if ($this->bypassAccessCheckForRecords) {
7135
            $columns = GeneralUtility::trimExplode(',', $fieldList, true);
7136
7137
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7138
            $queryBuilder->getRestrictions()->removeAll();
7139
7140
            $record = $queryBuilder->select(...$columns)
7141
                ->from($table)
7142
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7143
                ->execute()
7144
                ->fetchAssociative();
7145
7146
            return $record ?: false;
7147
        }
7148
        if (!$perms) {
7149
            throw new \RuntimeException('Internal ERROR: no permissions to check for non-admin user', 1270853920);
7150
        }
7151
        // For all tables: Check if record exists:
7152
        $isWebMountRestrictionIgnored = BackendUtility::isWebMountRestrictionIgnored($table);
7153
        if (is_array($GLOBALS['TCA'][$table]) && $id > 0 && ($this->admin || $isWebMountRestrictionIgnored || $this->isRecordInWebMount($table, $id))) {
7154
            $columns = GeneralUtility::trimExplode(',', $fieldList, true);
7155
            if ($table !== 'pages') {
7156
                // Find record without checking page
7157
                // @todo: This should probably check for editlock
7158
                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7159
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7160
                $output = $queryBuilder
7161
                    ->select(...$columns)
7162
                    ->from($table)
7163
                    ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7164
                    ->execute()
7165
                    ->fetchAssociative();
7166
                // If record found, check page as well:
7167
                if (is_array($output)) {
7168
                    // Looking up the page for record:
7169
                    $pageRec = $this->doesRecordExist_pageLookUp($output['pid'], $perms);
7170
                    // 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):
7171
                    $isRootLevelRestrictionIgnored = BackendUtility::isRootLevelRestrictionIgnored($table);
7172
                    if (is_array($pageRec) || !$output['pid'] && ($this->admin || $isRootLevelRestrictionIgnored)) {
7173
                        return $output;
7174
                    }
7175
                }
7176
                return false;
7177
            }
7178
            return $this->doesRecordExist_pageLookUp($id, $perms, $columns);
7179
        }
7180
        return false;
7181
    }
7182
7183
    /**
7184
     * Returns an array with record properties, like header and pid
7185
     * No check for deleted or access is done!
7186
     * For versionized records, pid is resolved to its live versions pid.
7187
     * Used for logging
7188
     *
7189
     * @param string $table Table name
7190
     * @param int $id Uid of record
7191
     * @param bool $noWSOL If set, no workspace overlay is performed
7192
     * @return array Properties of record
7193
     * @internal should only be used from within DataHandler
7194
     */
7195
    public function getRecordProperties($table, $id, $noWSOL = false)
7196
    {
7197
        $row = $table === 'pages' && !$id ? ['title' => '[root-level]', 'uid' => 0, 'pid' => 0] : $this->recordInfo($table, $id, '*');
7198
        if (!$noWSOL) {
7199
            BackendUtility::workspaceOL($table, $row);
7200
        }
7201
        return $this->getRecordPropertiesFromRow($table, $row);
7202
    }
7203
7204
    /**
7205
     * Returns an array with record properties, like header and pid, based on the row
7206
     *
7207
     * @param string $table Table name
7208
     * @param array $row Input row
7209
     * @return array|null Output array
7210
     * @internal should only be used from within DataHandler
7211
     */
7212
    public function getRecordPropertiesFromRow($table, $row)
7213
    {
7214
        if ($GLOBALS['TCA'][$table]) {
7215
            $liveUid = ($row['t3ver_oid'] ?? null) ? ($row['t3ver_oid'] ?? null) : ($row['uid'] ?? null);
7216
            return [
7217
                'header' => BackendUtility::getRecordTitle($table, $row),
7218
                'pid' => $row['pid'] ?? null,
7219
                'event_pid' => $this->eventPid($table, (int)$liveUid, $row['pid'] ?? null),
7220
                't3ver_state' => BackendUtility::isTableWorkspaceEnabled($table) ? ($row['t3ver_state'] ?? '') : '',
7221
            ];
7222
        }
7223
        return null;
7224
    }
7225
7226
    /**
7227
     * @param string $table
7228
     * @param int $uid
7229
     * @param int $pid
7230
     * @return int
7231
     * @internal should only be used from within DataHandler
7232
     */
7233
    public function eventPid($table, $uid, $pid)
7234
    {
7235
        return $table === 'pages' ? $uid : $pid;
7236
    }
7237
7238
    /*********************************************
7239
     *
7240
     * Storing data to Database Layer
7241
     *
7242
     ********************************************/
7243
    /**
7244
     * Update database record
7245
     * Does not check permissions but expects them to be verified on beforehand
7246
     *
7247
     * @param string $table Record table name
7248
     * @param int $id Record uid
7249
     * @param array $fieldArray Array of field=>value pairs to insert. FIELDS MUST MATCH the database FIELDS. No check is done.
7250
     * @internal should only be used from within DataHandler
7251
     */
7252
    public function updateDB($table, $id, $fieldArray)
7253
    {
7254
        if (is_array($fieldArray) && is_array($GLOBALS['TCA'][$table]) && (int)$id) {
7255
            // Do NOT update the UID field, ever!
7256
            unset($fieldArray['uid']);
7257
            if (!empty($fieldArray)) {
7258
                $fieldArray = $this->insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray);
7259
7260
                $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
7261
7262
                $types = [];
7263
                $platform = $connection->getDatabasePlatform();
7264
                if ($platform instanceof SQLServerPlatform) {
7265
                    // mssql needs to set proper PARAM_LOB and others to update fields
7266
                    $tableDetails = $connection->getSchemaManager()->listTableDetails($table);
7267
                    foreach ($fieldArray as $columnName => $columnValue) {
7268
                        $types[$columnName] = $tableDetails->getColumn($columnName)->getType()->getBindingType();
7269
                    }
7270
                }
7271
7272
                // Execute the UPDATE query:
7273
                $updateErrorMessage = '';
7274
                try {
7275
                    $connection->update($table, $fieldArray, ['uid' => (int)$id], $types);
7276
                } catch (DBALException $e) {
7277
                    $updateErrorMessage = $e->getPrevious()->getMessage();
7278
                }
7279
                // If succeeds, do...:
7280
                if ($updateErrorMessage === '') {
7281
                    // Update reference index:
7282
                    $this->updateRefIndex($table, $id);
7283
                    // Set History data
7284
                    $historyEntryId = 0;
7285
                    if (isset($this->historyRecords[$table . ':' . $id])) {
7286
                        $historyEntryId = $this->getRecordHistoryStore()->modifyRecord($table, $id, $this->historyRecords[$table . ':' . $id], $this->correlationId);
7287
                    }
7288
                    if ($this->enableLogging) {
7289
                        if ($this->checkStoredRecords) {
7290
                            $newRow = $this->checkStoredRecord($table, $id, $fieldArray, SystemLogDatabaseAction::UPDATE) ?? [];
7291
                        } else {
7292
                            $newRow = $fieldArray;
7293
                            $newRow['uid'] = $id;
7294
                        }
7295
                        // Set log entry:
7296
                        $propArr = $this->getRecordPropertiesFromRow($table, $newRow);
7297
                        $isOfflineVersion = (bool)($newRow['t3ver_oid'] ?? 0);
7298
                        $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']);
7299
                    }
7300
                    // Clear cache for relevant pages:
7301
                    $this->registerRecordIdForPageCacheClearing($table, $id);
7302
                    // Unset the pageCache for the id if table was page.
7303
                    if ($table === 'pages') {
7304
                        unset($this->pageCache[$id]);
7305
                    }
7306
                } else {
7307
                    $this->log($table, $id, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'SQL error: \'%s\' (%s)', 12, [$updateErrorMessage, $table . ':' . $id]);
7308
                }
7309
            }
7310
        }
7311
    }
7312
7313
    /**
7314
     * Insert into database
7315
     * Does not check permissions but expects them to be verified on beforehand
7316
     *
7317
     * @param string $table Record table name
7318
     * @param string $id "NEW...." uid string
7319
     * @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!
7320
     * @param bool $newVersion Set to TRUE if new version is created.
7321
     * @param int $suggestedUid Suggested UID value for the inserted record. See the array $this->suggestedInsertUids; Admin-only feature
7322
     * @param bool $dontSetNewIdIndex If TRUE, the ->substNEWwithIDs array is not updated. Only useful in very rare circumstances!
7323
     * @return int|null Returns ID on success.
7324
     * @internal should only be used from within DataHandler
7325
     */
7326
    public function insertDB($table, $id, $fieldArray, $newVersion = false, $suggestedUid = 0, $dontSetNewIdIndex = false)
7327
    {
7328
        if (is_array($fieldArray) && is_array($GLOBALS['TCA'][$table]) && isset($fieldArray['pid'])) {
7329
            // Do NOT insert the UID field, ever!
7330
            unset($fieldArray['uid']);
7331
            if (!empty($fieldArray)) {
7332
                // Check for "suggestedUid".
7333
                // This feature is used by the import functionality to force a new record to have a certain UID value.
7334
                // This is only recommended for use when the destination server is a passive mirror of another server.
7335
                // As a security measure this feature is available only for Admin Users (for now)
7336
                $suggestedUid = (int)$suggestedUid;
7337
                if ($this->BE_USER->isAdmin() && $suggestedUid && $this->suggestedInsertUids[$table . ':' . $suggestedUid]) {
7338
                    // When the value of ->suggestedInsertUids[...] is "DELETE" it will try to remove the previous record
7339
                    if ($this->suggestedInsertUids[$table . ':' . $suggestedUid] === 'DELETE') {
7340
                        $this->hardDeleteSingleRecord($table, (int)$suggestedUid);
7341
                    }
7342
                    $fieldArray['uid'] = $suggestedUid;
7343
                }
7344
                $fieldArray = $this->insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray);
7345
                $typeArray = [];
7346
                if (!empty($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'])
7347
                    && array_key_exists($GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'], $fieldArray)
7348
                ) {
7349
                    $typeArray[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']] = Connection::PARAM_LOB;
7350
                }
7351
                $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
7352
                $insertErrorMessage = '';
7353
                try {
7354
                    // Execute the INSERT query:
7355
                    $connection->insert(
7356
                        $table,
7357
                        $fieldArray,
7358
                        $typeArray
7359
                    );
7360
                } catch (DBALException $e) {
7361
                    $insertErrorMessage = $e->getPrevious()->getMessage();
7362
                }
7363
                // If succees, do...:
7364
                if ($insertErrorMessage === '') {
7365
                    // Set mapping for NEW... -> real uid:
7366
                    // the NEW_id now holds the 'NEW....' -id
7367
                    $NEW_id = $id;
7368
                    $id = $this->postProcessDatabaseInsert($connection, $table, $suggestedUid);
7369
7370
                    if (!$dontSetNewIdIndex) {
7371
                        $this->substNEWwithIDs[$NEW_id] = $id;
7372
                        $this->substNEWwithIDs_table[$NEW_id] = $table;
7373
                    }
7374
                    $newRow = [];
7375
                    if ($this->enableLogging) {
7376
                        // Checking the record is properly saved if configured
7377
                        if ($this->checkStoredRecords) {
7378
                            $newRow = $this->checkStoredRecord($table, $id, $fieldArray, SystemLogDatabaseAction::INSERT) ?? [];
7379
                        } else {
7380
                            $newRow = $fieldArray;
7381
                            $newRow['uid'] = $id;
7382
                        }
7383
                    }
7384
                    // Update reference index:
7385
                    $this->updateRefIndex($table, $id);
7386
7387
                    // Store in history
7388
                    $this->getRecordHistoryStore()->addRecord($table, $id, $newRow, $this->correlationId);
7389
7390
                    if ($newVersion) {
7391
                        if ($this->enableLogging) {
7392
                            $propArr = $this->getRecordPropertiesFromRow($table, $newRow);
7393
                            $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);
7394
                        }
7395
                    } else {
7396
                        if ($this->enableLogging) {
7397
                            $propArr = $this->getRecordPropertiesFromRow($table, $newRow);
7398
                            $page_propArr = $this->getRecordProperties('pages', $propArr['pid']);
7399
                            $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);
7400
                        }
7401
                        // Clear cache for relevant pages:
7402
                        $this->registerRecordIdForPageCacheClearing($table, $id);
7403
                    }
7404
                    return $id;
7405
                }
7406
                $this->log($table, 0, SystemLogDatabaseAction::INSERT, 0, SystemLogErrorClassification::SYSTEM_ERROR, 'SQL error: \'%s\' (%s)', 12, [$insertErrorMessage, $table . ':' . $id]);
7407
            }
7408
        }
7409
        return null;
7410
    }
7411
7412
    /**
7413
     * Checking stored record to see if the written values are properly updated.
7414
     *
7415
     * @param string $table Record table name
7416
     * @param int $id Record uid
7417
     * @param array $fieldArray Array of field=>value pairs to insert/update
7418
     * @param int $action Action, for logging only.
7419
     * @return array|null Selected row
7420
     * @see insertDB()
7421
     * @see updateDB()
7422
     * @internal should only be used from within DataHandler
7423
     */
7424
    public function checkStoredRecord($table, $id, $fieldArray, $action)
7425
    {
7426
        $id = (int)$id;
7427
        if (is_array($GLOBALS['TCA'][$table]) && $id) {
7428
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7429
            $queryBuilder->getRestrictions()->removeAll();
7430
7431
            $row = $queryBuilder
7432
                ->select('*')
7433
                ->from($table)
7434
                ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
7435
                ->execute()
7436
                ->fetchAssociative();
7437
7438
            if (!empty($row)) {
7439
                // Traverse array of values that was inserted into the database and compare with the actually stored value:
7440
                $errors = [];
7441
                foreach ($fieldArray as $key => $value) {
7442
                    if (!$this->checkStoredRecords_loose || $value || $row[$key]) {
7443
                        if (is_float($row[$key])) {
7444
                            // if the database returns the value as double, compare it as double
7445
                            if ((double)$value !== (double)$row[$key]) {
7446
                                $errors[] = $key;
7447
                            }
7448
                        } else {
7449
                            $dbType = $GLOBALS['TCA'][$table]['columns'][$key]['config']['dbType'] ?? false;
7450
                            if ($dbType === 'datetime' || $dbType === 'time') {
7451
                                $row[$key] = $this->normalizeTimeFormat($table, $row[$key], $dbType);
7452
                            }
7453
                            if ((string)$value !== (string)$row[$key]) {
7454
                                // The is_numeric check catches cases where we want to store a float/double value
7455
                                // and database returns the field as a string with the least required amount of
7456
                                // significant digits, i.e. "0.00" being saved and "0" being read back.
7457
                                if (is_numeric($value) && is_numeric($row[$key])) {
7458
                                    if ((double)$value === (double)$row[$key]) {
7459
                                        continue;
7460
                                    }
7461
                                }
7462
                                $errors[] = $key;
7463
                            }
7464
                        }
7465
                    }
7466
                }
7467
                // Set log message if there were fields with unmatching values:
7468
                if (!empty($errors)) {
7469
                    $message = sprintf(
7470
                        '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.',
7471
                        $id,
7472
                        $table,
7473
                        implode(', ', $errors)
7474
                    );
7475
                    $this->log($table, $id, $action, 0, SystemLogErrorClassification::USER_ERROR, $message);
7476
                }
7477
                // Return selected rows:
7478
                return $row;
7479
            }
7480
        }
7481
        return null;
7482
    }
7483
7484
    /**
7485
     * Setting sys_history record, based on content previously set in $this->historyRecords[$table . ':' . $id] (by compareFieldArrayWithCurrentAndUnset())
7486
     *
7487
     * This functionality is now moved into the RecordHistoryStore and can be used instead.
7488
     *
7489
     * @param string $table Table name
7490
     * @param int $id Record ID
7491
     * @internal should only be used from within DataHandler
7492
     */
7493
    public function setHistory($table, $id)
7494
    {
7495
        if (isset($this->historyRecords[$table . ':' . $id])) {
7496
            $this->getRecordHistoryStore()->modifyRecord(
7497
                $table,
7498
                $id,
7499
                $this->historyRecords[$table . ':' . $id],
7500
                $this->correlationId
7501
            );
7502
        }
7503
    }
7504
7505
    /**
7506
     * @return RecordHistoryStore
7507
     */
7508
    protected function getRecordHistoryStore(): RecordHistoryStore
7509
    {
7510
        return GeneralUtility::makeInstance(
7511
            RecordHistoryStore::class,
7512
            RecordHistoryStore::USER_BACKEND,
7513
            $this->BE_USER->user['uid'],
7514
            (int)$this->BE_USER->getOriginalUserIdWhenInSwitchUserMode(),
7515
            $GLOBALS['EXEC_TIME'],
7516
            $this->BE_USER->workspace
7517
        );
7518
    }
7519
7520
    /**
7521
     * Register a table/uid combination in current user workspace for reference updating.
7522
     * Should be called on almost any update to a record which could affect references inside the record.
7523
     *
7524
     * @param string $table Table name
7525
     * @param int $uid Record UID
7526
     * @param int $workspace Workspace the record lives in
7527
     * @internal should only be used from within DataHandler
7528
     */
7529
    public function updateRefIndex($table, $uid, int $workspace = null): void
7530
    {
7531
        if ($workspace === null) {
7532
            $workspace = (int)$this->BE_USER->workspace;
7533
        }
7534
        $this->referenceIndexUpdater->registerForUpdate((string)$table, (int)$uid, $workspace);
7535
    }
7536
7537
    /**
7538
     * Delete rows from sys_refindex a table / uid combination is involved in:
7539
     * Either on left side (tablename + recuid) OR right side (ref_table + ref_uid).
7540
     * Useful in scenarios like workspace-discard where parents or children are hard deleted: The
7541
     * expensive updateRefIndex() does not need to be called since we can just drop straight ahead.
7542
     *
7543
     * @param string $table Table name, used as tablename and ref_table
7544
     * @param int $uid Record uid, used as recuid and ref_uid
7545
     * @param int $workspace Workspace the record lives in
7546
     * @internal should only be used from within DataHandler
7547
     */
7548
    public function registerReferenceIndexRowsForDrop(string $table, int $uid, int $workspace): void
7549
    {
7550
        $this->referenceIndexUpdater->registerForDrop($table, $uid, $workspace);
7551
    }
7552
7553
    /**
7554
     * Helper method to access referenceIndexUpdater->registerUpdateForReferencesToItem()
7555
     * from within workspace DataHandlerHook.
7556
     *
7557
     * @internal Exists only for workspace DataHandlerHook. May vanish any time.
7558
     */
7559
    public function registerReferenceIndexUpdateForReferencesToItem(string $table, int $uid, int $workspace, int $targetWorkspace = null): void
7560
    {
7561
        $this->referenceIndexUpdater->registerUpdateForReferencesToItem($table, $uid, $workspace, $targetWorkspace);
7562
    }
7563
7564
    /*********************************************
7565
     *
7566
     * Misc functions
7567
     *
7568
     ********************************************/
7569
    /**
7570
     * Returning sorting number for tables with a "sortby" column
7571
     * Using when new records are created and existing records are moved around.
7572
     *
7573
     * The strategy is:
7574
     *  - if no record exists: set interval as sorting number
7575
     *  - if inserted before an element: put in the middle of the existing elements
7576
     *  - if inserted behind the last element: add interval to last sorting number
7577
     *  - if collision: move all subsequent records by 2 * interval, insert new record with collision + interval
7578
     *
7579
     * How to calculate the maximum possible inserts for the worst case of adding all records to the top,
7580
     * such that the sorting number stays within INT_MAX
7581
     *
7582
     * i = interval (currently 256)
7583
     * c = number of inserts until collision
7584
     * s = max sorting number to reach (INT_MAX - 32bit)
7585
     * n = number of records (~83 million)
7586
     *
7587
     * c = 2 * g
7588
     * g = log2(i) / 2 + 1
7589
     * n = g * s / i - g + 1
7590
     *
7591
     * The algorithm can be tuned by adjusting the interval value.
7592
     * Higher value means less collisions, but also less inserts are possible to stay within INT_MAX.
7593
     *
7594
     * @param string $table Table name
7595
     * @param int $uid Uid of record to find sorting number for. May be zero in case of new.
7596
     * @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)
7597
     * @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.
7598
     * @internal should only be used from within DataHandler
7599
     */
7600
    public function getSortNumber($table, $uid, $pid)
7601
    {
7602
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
7603
        if (!$sortColumn) {
7604
            return null;
7605
        }
7606
7607
        $considerWorkspaces = BackendUtility::isTableWorkspaceEnabled($table);
7608
        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
7609
        $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
7610
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7611
7612
        $queryBuilder
7613
            ->select($sortColumn, 'pid', 'uid')
7614
            ->from($table);
7615
        if ($considerWorkspaces) {
7616
            $queryBuilder->addSelect('t3ver_state');
7617
        }
7618
7619
        // find and return the sorting value for the first record on that pid
7620
        if ($pid >= 0) {
7621
            // Fetches the first record (lowest sorting) under this pid
7622
            $queryBuilder
7623
                ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)));
7624
7625
            if ($considerWorkspaces) {
7626
                $queryBuilder->andWhere(
7627
                    $queryBuilder->expr()->orX(
7628
                        $queryBuilder->expr()->eq('t3ver_oid', 0),
7629
                        $queryBuilder->expr()->eq('t3ver_state', VersionState::MOVE_POINTER)
7630
                    )
7631
                );
7632
            }
7633
            $row = $queryBuilder
7634
                ->orderBy($sortColumn, 'ASC')
7635
                ->addOrderBy('uid', 'ASC')
7636
                ->setMaxResults(1)
7637
                ->execute()
7638
                ->fetchAssociative();
7639
7640
            if (!empty($row)) {
7641
                // The top record was the record itself, so we return its current sorting value
7642
                if ($row['uid'] == $uid) {
7643
                    return $row[$sortColumn];
7644
                }
7645
                // If the record sorting value < 1 we must resort all the records under this pid
7646
                if ($row[$sortColumn] < 1) {
7647
                    $this->increaseSortingOfFollowingRecords($table, (int)$pid);
7648
                    // Lowest sorting value after full resorting is $sortIntervals
7649
                    return $this->sortIntervals;
7650
                }
7651
                // Sorting number between current top element and zero
7652
                return floor($row[$sortColumn] / 2);
7653
            }
7654
            // No records, so we choose the default value as sorting-number
7655
            return $this->sortIntervals;
7656
        }
7657
7658
        // Find and return first possible sorting value AFTER record with given uid ($pid)
7659
        // Fetches the record which is supposed to be the prev record
7660
        $row = $queryBuilder
7661
                ->where($queryBuilder->expr()->eq(
7662
                    'uid',
7663
                    $queryBuilder->createNamedParameter(abs($pid), \PDO::PARAM_INT)
7664
                ))
7665
                ->execute()
7666
                ->fetchAssociative();
7667
7668
        // There is a previous record
7669
        if (!empty($row)) {
7670
            $row += [
7671
                't3ver_state' => 0,
7672
                'uid' => 0,
7673
            ];
7674
            // Look if the record UID happens to be a versioned record. If so, find its live version.
7675
            // If this is already a moved record in workspace, this is not needed
7676
            if ((int)$row['t3ver_state'] !== VersionState::MOVE_POINTER && $lookForLiveVersion = BackendUtility::getLiveVersionOfRecord($table, $row['uid'], $sortColumn . ',pid,uid')) {
7677
                $row = $lookForLiveVersion;
7678
            } elseif ($considerWorkspaces && $this->BE_USER->workspace > 0) {
7679
                // In case the previous record is moved in the workspace, we need to fetch the information from this specific record
7680
                $versionedRecord = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $row['uid'], $sortColumn . ',pid,uid,t3ver_state');
7681
                if (is_array($versionedRecord) && (int)$versionedRecord['t3ver_state'] === VersionState::MOVE_POINTER) {
7682
                    $row = $versionedRecord;
7683
                }
7684
            }
7685
            // If the record should be inserted after itself, keep the current sorting information:
7686
            if ((int)$row['uid'] === (int)$uid) {
7687
                $sortNumber = $row[$sortColumn];
7688
            } else {
7689
                $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
7690
                $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7691
7692
                $queryBuilder
7693
                        ->select($sortColumn, 'pid', 'uid')
7694
                        ->from($table)
7695
                        ->where(
7696
                            $queryBuilder->expr()->eq(
7697
                                'pid',
7698
                                $queryBuilder->createNamedParameter($row['pid'], \PDO::PARAM_INT)
7699
                            ),
7700
                            $queryBuilder->expr()->gte(
7701
                                $sortColumn,
7702
                                $queryBuilder->createNamedParameter($row[$sortColumn], \PDO::PARAM_INT)
7703
                            )
7704
                        )
7705
                        ->orderBy($sortColumn, 'ASC')
7706
                        ->addOrderBy('uid', 'DESC')
7707
                        ->setMaxResults(2);
7708
7709
                if ($considerWorkspaces) {
7710
                    $queryBuilder->andWhere(
7711
                        $queryBuilder->expr()->orX(
7712
                            $queryBuilder->expr()->eq('t3ver_oid', 0),
7713
                            $queryBuilder->expr()->eq('t3ver_state', VersionState::MOVE_POINTER)
7714
                        )
7715
                    );
7716
                }
7717
7718
                $subResults = $queryBuilder
7719
                    ->execute()
7720
                    ->fetchAllAssociative();
7721
                // Fetches the next record in order to calculate the in-between sortNumber
7722
                // There was a record afterwards
7723
                if (count($subResults) === 2) {
7724
                    // There was a record afterwards, fetch that
7725
                    $subrow = array_pop($subResults);
7726
                    // The sortNumber is found in between these values
7727
                    $sortNumber = $row[$sortColumn] + floor(($subrow[$sortColumn] - $row[$sortColumn]) / 2);
7728
                    // The sortNumber happened NOT to be between the two surrounding numbers, so we'll have to resort the list
7729
                    if ($sortNumber <= $row[$sortColumn] || $sortNumber >= $subrow[$sortColumn]) {
7730
                        $this->increaseSortingOfFollowingRecords($table, (int)$row['pid'], (int)$row[$sortColumn]);
7731
                        $sortNumber = $row[$sortColumn] + $this->sortIntervals;
7732
                    }
7733
                } else {
7734
                    // If after the last record in the list, we just add the sortInterval to the last sortvalue
7735
                    $sortNumber = $row[$sortColumn] + $this->sortIntervals;
7736
                }
7737
            }
7738
            return ['pid' => $row['pid'], 'sortNumber' => $sortNumber];
7739
        }
7740
        if ($this->enableLogging) {
7741
            $propArr = $this->getRecordProperties($table, $uid);
7742
            // OK, don't insert $propArr['event_pid'] here...
7743
            $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']);
7744
        }
7745
        // There MUST be a previous record or else this cannot work
7746
        return false;
7747
    }
7748
7749
    /**
7750
     * Increases sorting field value of all records with sorting higher than $sortingValue
7751
     *
7752
     * Used internally by getSortNumber() to "make space" in sorting values when inserting new record
7753
     *
7754
     * @param string $table Table name
7755
     * @param int $pid Page Uid in which to resort records
7756
     * @param int $sortingValue All sorting numbers larger than this number will be shifted
7757
     * @see getSortNumber()
7758
     */
7759
    protected function increaseSortingOfFollowingRecords(string $table, int $pid, int $sortingValue = null): void
7760
    {
7761
        $sortBy = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
7762
        if ($sortBy) {
7763
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7764
7765
            $queryBuilder
7766
                ->update($table)
7767
                ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)))
7768
                ->set($sortBy, $queryBuilder->quoteIdentifier($sortBy) . ' + ' . $this->sortIntervals . ' + ' . $this->sortIntervals, false);
7769
            if ($sortingValue !== null) {
7770
                $queryBuilder->andWhere($queryBuilder->expr()->gt($sortBy, $sortingValue));
7771
            }
7772
            if (BackendUtility::isTableWorkspaceEnabled($table)) {
7773
                $queryBuilder
7774
                    ->andWhere(
7775
                        $queryBuilder->expr()->eq('t3ver_oid', 0)
7776
                    );
7777
            }
7778
7779
            $deleteColumn = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? '';
7780
            if ($deleteColumn) {
7781
                $queryBuilder->andWhere($queryBuilder->expr()->eq($deleteColumn, 0));
7782
            }
7783
7784
            $queryBuilder->execute();
7785
        }
7786
    }
7787
7788
    /**
7789
     * Returning uid of "previous" localized record, if any, for tables with a "sortby" column.
7790
     * Used when records are localized, so that localized records are sorted in the
7791
     * same order as the source language records.
7792
     *
7793
     * The uid of the returned record is later used to create the localized record "after"
7794
     * (higher sorting value) than the one the uid is returned of.
7795
     *
7796
     * There are basically two scenarios:
7797
     * * The localized record is to be placed as the first record of the target pid/language
7798
     *   combination. In this case, there is no "before" record in this language. The method
7799
     *   returns input $uid, saying "insert the localized record with a higher sorting value
7800
     *   than the record the localization is created from".
7801
     * * There is a localized record "before" (lower sorting value) in the target pid/language
7802
     *   combination. For instance because source language element 2 is being translated and
7803
     *   source language element 1 has already been translated. In this case, the uid of the
7804
     *   'element 1' is returned, saying "insert the localized record with a higher sorting
7805
     *   value than the "before" record in this language.
7806
     *
7807
     * The algorithm first fetches the record of given input uid. It then looks if there is a
7808
     * record with a lower sorting value for this pid/language combination. If no, input uid
7809
     * is returned ("place with higher sorting than source language record"). If yes, it looks
7810
     * if there is a localization of that source record in the target language and return the
7811
     * uid of that target language record ("place with higher sorting that this traget language
7812
     * record"). When dealing with table tt_content, colpos is also taken into account.
7813
     *
7814
     * @param string $table Table name
7815
     * @param int $uid Uid of source language record
7816
     * @param int $pid Pid of source language record
7817
     * @param int $targetLanguage Target language id
7818
     * @return int uid of record after which the localized record should be inserted
7819
     */
7820
    protected function getPreviousLocalizedRecordUid($table, $uid, $pid, $targetLanguage)
7821
    {
7822
        $previousLocalizedRecordUid = $uid;
7823
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
7824
        if (!$sortColumn) {
7825
            return $previousLocalizedRecordUid;
7826
        }
7827
7828
        // Typically l10n_parent
7829
        $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'];
7830
        // Typically sys_language_uid
7831
        $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'];
7832
7833
        $select = [$sortColumn, $languageField, $transOrigPointerField, 'pid', 'uid'];
7834
        // For content elements, we also need the colPos
7835
        if ($table === 'tt_content') {
7836
            $select[] = 'colPos';
7837
        }
7838
7839
        // Get the sort value and some other details of the source language record
7840
        $row = BackendUtility::getRecord($table, $uid, implode(',', $select));
7841
        if (!is_array($row)) {
7842
            // This if may be obsolete ... didn't the callee already check if the source record exists?
7843
            return $previousLocalizedRecordUid;
7844
        }
7845
7846
        // Try to find a "before" record in source language
7847
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
7848
        $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
7849
        $queryBuilder
7850
            ->select(...$select)
7851
            ->from($table)
7852
            ->where(
7853
                $queryBuilder->expr()->eq(
7854
                    'pid',
7855
                    $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)
7856
                ),
7857
                $queryBuilder->expr()->eq(
7858
                    $languageField,
7859
                    $queryBuilder->createNamedParameter($row[$languageField], \PDO::PARAM_INT)
7860
                ),
7861
                $queryBuilder->expr()->lt(
7862
                    $sortColumn,
7863
                    $queryBuilder->createNamedParameter($row[$sortColumn], \PDO::PARAM_INT)
7864
                )
7865
            )
7866
            ->orderBy($sortColumn, 'DESC')
7867
            ->addOrderBy('uid', 'DESC')
7868
            ->setMaxResults(1);
7869
        if ($table === 'tt_content') {
7870
            $queryBuilder->andWhere(
7871
                $queryBuilder->expr()->eq(
7872
                    'colPos',
7873
                    $queryBuilder->createNamedParameter($row['colPos'], \PDO::PARAM_INT)
7874
                )
7875
            );
7876
        }
7877
        // If there is a "before" record in source language, see if it is localized to target language.
7878
        // If so, return uid of target language record.
7879
        if ($previousRow = $queryBuilder->execute()->fetchAssociative()) {
7880
            $previousLocalizedRecord = BackendUtility::getRecordLocalization($table, $previousRow['uid'], $targetLanguage, 'pid=' . (int)$pid);
7881
            if (isset($previousLocalizedRecord[0]) && is_array($previousLocalizedRecord[0])) {
7882
                $previousLocalizedRecordUid = $previousLocalizedRecord[0]['uid'];
7883
            }
7884
        }
7885
7886
        return $previousLocalizedRecordUid;
7887
    }
7888
7889
    /**
7890
     * 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.
7891
     * Used for new records and during copy operations for defaults
7892
     *
7893
     * @param string $table Table name for which to set default values.
7894
     * @return array Array with default values.
7895
     * @internal should only be used from within DataHandler
7896
     */
7897
    public function newFieldArray($table)
7898
    {
7899
        $fieldArray = [];
7900
        if (is_array($GLOBALS['TCA'][$table]['columns'])) {
7901
            foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $content) {
7902
                if (isset($this->defaultValues[$table][$field])) {
7903
                    $fieldArray[$field] = $this->defaultValues[$table][$field];
7904
                } elseif (isset($content['config']['default'])) {
7905
                    $fieldArray[$field] = $content['config']['default'];
7906
                }
7907
            }
7908
        }
7909
        return $fieldArray;
7910
    }
7911
7912
    /**
7913
     * 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.
7914
     *
7915
     * @param string $table Table name
7916
     * @param array $incomingFieldArray Incoming array (passed by reference)
7917
     * @param int $pageId the PID of the table (where the record should be inserted)
7918
     * @internal should only be used from within DataHandler
7919
     */
7920
    protected function addDefaultPermittedLanguageIfNotSet(string $table, &$incomingFieldArray, int $pageId): void
7921
    {
7922
        $languageFieldName = $GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? '';
7923
        if (empty($languageFieldName)) {
7924
            return;
7925
        }
7926
        if (isset($incomingFieldArray[$languageFieldName])) {
7927
            return;
7928
        }
7929
        try {
7930
            $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
7931
            // Checking languages
7932
            foreach ($site->getAvailableLanguages($this->BE_USER, false, $pageId) as $languageId => $language) {
7933
                $incomingFieldArray[$languageFieldName] = $languageId;
7934
                break;
7935
            }
7936
        } catch (SiteNotFoundException $e) {
7937
            // No site found, do not set a default language if nothing was set explicitly
7938
            return;
7939
        }
7940
    }
7941
7942
    /**
7943
     * Find a site language by the given language ID for a specific page, and check for all available sites
7944
     * if the page ID is "0".
7945
     *
7946
     * Note: Currently, the first language matching the given id is used, while
7947
     *       there might be more languages with the same id in additional sites.
7948
     *
7949
     * @param int $pageId
7950
     * @param int $languageId
7951
     * @return SiteLanguage|null
7952
     */
7953
    protected function getSiteLanguageForPage(int $pageId, int $languageId): ?SiteLanguage
7954
    {
7955
        try {
7956
            // Try to fetch the site language from the pages' associated site
7957
            $site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
7958
            return $site->getLanguageById($languageId);
7959
        } catch (SiteNotFoundException | \InvalidArgumentException $e) {
7960
            // In case no site language could be found, we might deal with the root node,
7961
            // we therefore try to fetch the site language from all available sites.
7962
            // NOTE: This has side effects, in case the SAME ID is used for different languages in different sites!
7963
            $sites = GeneralUtility::makeInstance(SiteFinder::class)->getAllSites();
7964
            foreach ($sites as $site) {
7965
                try {
7966
                    return $site->getLanguageById($languageId);
7967
                } catch (\InvalidArgumentException $e) {
7968
                    // language not found in site, continue
7969
                    continue;
7970
                }
7971
            }
7972
        }
7973
7974
        return null;
7975
    }
7976
7977
    /**
7978
     * Returns the $data array from $table overridden in the fields defined in ->overrideValues.
7979
     *
7980
     * @param string $table Table name
7981
     * @param array $data Data array with fields from table. These will be overlaid with values in $this->overrideValues[$table]
7982
     * @return array Data array, processed.
7983
     * @internal should only be used from within DataHandler
7984
     */
7985
    public function overrideFieldArray($table, $data)
7986
    {
7987
        if (isset($this->overrideValues[$table]) && is_array($this->overrideValues[$table])) {
7988
            $data = array_merge($data, $this->overrideValues[$table]);
7989
        }
7990
        return $data;
7991
    }
7992
7993
    /**
7994
     * Compares the incoming field array with the current record and unsets all fields which are the same.
7995
     * Used for existing records being updated
7996
     *
7997
     * @param string $table Record table name
7998
     * @param int $id Record uid
7999
     * @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!
8000
     * @return array Returns $fieldArray. If the returned array is empty, then the record should not be updated!
8001
     * @internal should only be used from within DataHandler
8002
     */
8003
    public function compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray)
8004
    {
8005
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
8006
        $queryBuilder = $connection->createQueryBuilder();
8007
        $queryBuilder->getRestrictions()->removeAll();
8008
        $currentRecord = $queryBuilder->select('*')
8009
            ->from($table)
8010
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($id, \PDO::PARAM_INT)))
8011
            ->execute()
8012
            ->fetchAssociative();
8013
        // If the current record exists (which it should...), begin comparison:
8014
        if (is_array($currentRecord)) {
8015
            $tableDetails = $connection->getSchemaManager()->listTableDetails($table);
8016
            $columnRecordTypes = [];
8017
            foreach ($currentRecord as $columnName => $_) {
8018
                $columnRecordTypes[$columnName] = '';
8019
                $type = $tableDetails->getColumn($columnName)->getType();
8020
                if ($type instanceof IntegerType) {
8021
                    $columnRecordTypes[$columnName] = 'int';
8022
                }
8023
            }
8024
            // Unset the fields which are similar:
8025
            foreach ($fieldArray as $col => $val) {
8026
                $fieldConfiguration = $GLOBALS['TCA'][$table]['columns'][$col]['config'] ?? [];
8027
                $isNullField = (!empty($fieldConfiguration['eval']) && GeneralUtility::inList($fieldConfiguration['eval'], 'null'));
8028
8029
                // Unset fields if stored and submitted values are equal - except the current field holds MM relations.
8030
                // In general this avoids to store superfluous data which also will be visualized in the editing history.
8031
                if (empty($fieldConfiguration['MM']) && $this->isSubmittedValueEqualToStoredValue($val, $currentRecord[$col], $columnRecordTypes[$col], $isNullField)) {
8032
                    unset($fieldArray[$col]);
8033
                } else {
8034
                    if (!isset($this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col])) {
8035
                        $this->historyRecords[$table . ':' . $id]['oldRecord'][$col] = $currentRecord[$col];
8036
                    } elseif ($this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col] != $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col]) {
8037
                        $this->historyRecords[$table . ':' . $id]['oldRecord'][$col] = $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col];
8038
                    }
8039
                    if (!isset($this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col])) {
8040
                        $this->historyRecords[$table . ':' . $id]['newRecord'][$col] = $fieldArray[$col];
8041
                    } elseif ($this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col] != $this->mmHistoryRecords[$table . ':' . $id]['oldRecord'][$col]) {
8042
                        $this->historyRecords[$table . ':' . $id]['newRecord'][$col] = $this->mmHistoryRecords[$table . ':' . $id]['newRecord'][$col];
8043
                    }
8044
                }
8045
            }
8046
        } else {
8047
            // If the current record does not exist this is an error anyways and we just return an empty array here.
8048
            $fieldArray = [];
8049
        }
8050
        return $fieldArray;
8051
    }
8052
8053
    /**
8054
     * Determines whether submitted values and stored values are equal.
8055
     * This prevents from adding superfluous field changes which would be shown in the record history as well.
8056
     * For NULL fields (see accordant TCA definition 'eval' = 'null'), a special handling is required since
8057
     * (!strcmp(NULL, '')) would be a false-positive.
8058
     *
8059
     * @param mixed $submittedValue Value that has submitted (e.g. from a backend form)
8060
     * @param mixed $storedValue Value that is currently stored in the database
8061
     * @param string $storedType SQL type of the stored value column (see mysql_field_type(), e.g 'int', 'string',  ...)
8062
     * @param bool $allowNull Whether NULL values are allowed by accordant TCA definition ('eval' = 'null')
8063
     * @return bool Whether both values are considered to be equal
8064
     */
8065
    protected function isSubmittedValueEqualToStoredValue($submittedValue, $storedValue, $storedType, $allowNull = false)
8066
    {
8067
        // No NULL values are allowed, this is the regular behaviour.
8068
        // Thus, check whether strings are the same or whether integer values are empty ("0" or "").
8069
        if (!$allowNull) {
8070
            $result = (string)$submittedValue === (string)$storedValue || $storedType === 'int' && (int)$storedValue === (int)$submittedValue;
8071
        // Null values are allowed, but currently there's a real (not NULL) value.
8072
        // Thus, ensure no NULL value was submitted and fallback to the regular behaviour.
8073
        } elseif ($storedValue !== null) {
8074
            $result = (
8075
                $submittedValue !== null
8076
                && $this->isSubmittedValueEqualToStoredValue($submittedValue, $storedValue, $storedType, false)
8077
            );
8078
        // Null values are allowed, and currently there's a NULL value.
8079
        // Thus, check whether a NULL value was submitted.
8080
        } else {
8081
            $result = ($submittedValue === null);
8082
        }
8083
8084
        return $result;
8085
    }
8086
8087
    /**
8088
     * Disables the delete clause for fetching records.
8089
     * In general only undeleted records will be used. If the delete
8090
     * clause is disabled, also deleted records are taken into account.
8091
     */
8092
    public function disableDeleteClause()
8093
    {
8094
        $this->disableDeleteClause = true;
8095
    }
8096
8097
    /**
8098
     * Returns delete-clause for the $table
8099
     *
8100
     * @param string $table Table name
8101
     * @return string Delete clause
8102
     * @internal should only be used from within DataHandler
8103
     */
8104
    public function deleteClause($table)
8105
    {
8106
        // Returns the proper delete-clause if any for a table from TCA
8107
        if (!$this->disableDeleteClause && $GLOBALS['TCA'][$table]['ctrl']['delete']) {
8108
            return ' AND ' . $table . '.' . $GLOBALS['TCA'][$table]['ctrl']['delete'] . '=0';
8109
        }
8110
        return '';
8111
    }
8112
8113
    /**
8114
     * Add delete restriction if not disabled
8115
     *
8116
     * @param QueryRestrictionContainerInterface $restrictions
8117
     */
8118
    protected function addDeleteRestriction(QueryRestrictionContainerInterface $restrictions)
8119
    {
8120
        if (!$this->disableDeleteClause) {
8121
            $restrictions->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8122
        }
8123
    }
8124
8125
    /**
8126
     * Gets UID of parent record. If record is deleted it will be looked up in
8127
     * an array built before the record was deleted
8128
     *
8129
     * @param string $table Table where record lives/lived
8130
     * @param int $uid Record UID
8131
     * @return int[] Parent UIDs
8132
     */
8133
    protected function getOriginalParentOfRecord($table, $uid)
8134
    {
8135
        if (isset(self::$recordPidsForDeletedRecords[$table][$uid])) {
8136
            return self::$recordPidsForDeletedRecords[$table][$uid];
8137
        }
8138
        [$parentUid] = BackendUtility::getTSCpid($table, $uid, '');
8139
        return [$parentUid];
8140
    }
8141
8142
    /**
8143
     * Extract entries from TSconfig for a specific table. This will merge specific and default configuration together.
8144
     *
8145
     * @param string $table Table name
8146
     * @param array $TSconfig TSconfig for page
8147
     * @return array TSconfig merged
8148
     * @internal should only be used from within DataHandler
8149
     */
8150
    public function getTableEntries($table, $TSconfig)
8151
    {
8152
        $tA = is_array($TSconfig['table.'][$table . '.'] ?? false) ? $TSconfig['table.'][$table . '.'] : [];
8153
        $dA = is_array($TSconfig['default.'] ?? false) ? $TSconfig['default.'] : [];
8154
        ArrayUtility::mergeRecursiveWithOverrule($dA, $tA);
8155
        return $dA;
8156
    }
8157
8158
    /**
8159
     * Returns the pid of a record from $table with $uid
8160
     *
8161
     * @param string $table Table name
8162
     * @param int $uid Record uid
8163
     * @return int|false PID value (unless the record did not exist in which case FALSE is returned)
8164
     * @internal should only be used from within DataHandler
8165
     */
8166
    public function getPID($table, $uid)
8167
    {
8168
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8169
        $queryBuilder->getRestrictions()
8170
            ->removeAll();
8171
        $queryBuilder->select('pid')
8172
            ->from($table)
8173
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)));
8174
        if ($row = $queryBuilder->execute()->fetchAssociative()) {
8175
            return $row['pid'];
8176
        }
8177
        return false;
8178
    }
8179
8180
    /**
8181
     * Executing dbAnalysisStore
8182
     * This will save MM relations for new records but is executed after records are created because we need to know the ID of them
8183
     * @internal should only be used from within DataHandler
8184
     */
8185
    public function dbAnalysisStoreExec()
8186
    {
8187
        foreach ($this->dbAnalysisStore as $action) {
8188
            $idIsInteger = MathUtility::canBeInterpretedAsInteger($action[2]);
8189
            // If NEW id is not found in substitution array (due to errors), continue.
8190
            if (!$idIsInteger && !isset($this->substNEWwithIDs[$action[2]])) {
8191
                continue;
8192
            }
8193
            $id = BackendUtility::wsMapId($action[4], $idIsInteger ? $action[2] : $this->substNEWwithIDs[$action[2]]);
8194
            if ($id) {
8195
                $action[0]->writeMM($action[1], $id, $action[3]);
8196
            }
8197
        }
8198
    }
8199
8200
    /**
8201
     * Returns array, $CPtable, of pages under the $pid going down to $counter levels.
8202
     * Selecting ONLY pages which the user has read-access to!
8203
     *
8204
     * @param array $CPtable Accumulation of page uid=>pid pairs in branch of $pid
8205
     * @param int $pid Page ID for which to find subpages
8206
     * @param int $counter Number of levels to go down.
8207
     * @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!
8208
     * @return array Return array.
8209
     * @internal should only be used from within DataHandler
8210
     */
8211
    public function int_pageTreeInfo($CPtable, $pid, $counter, $rootID)
8212
    {
8213
        if ($counter) {
8214
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
8215
            $restrictions = $queryBuilder->getRestrictions()->removeAll();
8216
            $this->addDeleteRestriction($restrictions);
8217
            $queryBuilder
8218
                ->select('uid')
8219
                ->from('pages')
8220
                ->where($queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)))
8221
                ->orderBy('sorting', 'DESC');
8222
            if (!$this->admin) {
8223
                $queryBuilder->andWhere($this->BE_USER->getPagePermsClause(Permission::PAGE_SHOW));
8224
            }
8225
            if ((int)$this->BE_USER->workspace === 0) {
8226
                $queryBuilder->andWhere(
8227
                    $queryBuilder->expr()->eq('t3ver_wsid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
8228
                );
8229
            } else {
8230
                $queryBuilder->andWhere($queryBuilder->expr()->in(
8231
                    't3ver_wsid',
8232
                    $queryBuilder->createNamedParameter([0, $this->BE_USER->workspace], Connection::PARAM_INT_ARRAY)
8233
                ));
8234
            }
8235
            $result = $queryBuilder->execute();
8236
8237
            $pages = [];
8238
            while ($row = $result->fetchAssociative()) {
8239
                $pages[$row['uid']] = $row;
8240
            }
8241
8242
            // Resolve placeholders of workspace versions
8243
            if (!empty($pages) && (int)$this->BE_USER->workspace !== 0) {
8244
                $pages = array_reverse(
8245
                    $this->resolveVersionedRecords(
8246
                        'pages',
8247
                        'uid',
8248
                        'sorting',
8249
                        array_keys($pages)
8250
                    ),
8251
                    true
8252
                );
8253
            }
8254
8255
            foreach ($pages as $page) {
8256
                if ($page['uid'] != $rootID) {
8257
                    $CPtable[$page['uid']] = $pid;
8258
                    // If the uid is NOT the rootID of the copyaction and if we are supposed to walk further down
8259
                    if ($counter - 1) {
8260
                        $CPtable = $this->int_pageTreeInfo($CPtable, $page['uid'], $counter - 1, $rootID);
8261
                    }
8262
                }
8263
            }
8264
        }
8265
        return $CPtable;
8266
    }
8267
8268
    /**
8269
     * List of all tables (those administrators has access to = array_keys of $GLOBALS['TCA'])
8270
     *
8271
     * @return array Array of all TCA table names
8272
     * @internal should only be used from within DataHandler
8273
     */
8274
    public function compileAdminTables()
8275
    {
8276
        return array_keys($GLOBALS['TCA']);
8277
    }
8278
8279
    /**
8280
     * Checks if any uniqueInPid eval input fields are in the record and if so, they are re-written to be correct.
8281
     *
8282
     * @param string $table Table name
8283
     * @param int $uid Record UID
8284
     * @internal should only be used from within DataHandler
8285
     */
8286
    public function fixUniqueInPid($table, $uid)
8287
    {
8288
        if (empty($GLOBALS['TCA'][$table])) {
8289
            return;
8290
        }
8291
8292
        $curData = $this->recordInfo($table, $uid, '*');
8293
        $newData = [];
8294
        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $conf) {
8295
            if ($conf['config']['type'] === 'input' && (string)$curData[$field] !== '') {
8296
                $evalCodesArray = GeneralUtility::trimExplode(',', $conf['config']['eval'] ?? '', true);
8297
                if (in_array('uniqueInPid', $evalCodesArray, true)) {
8298
                    $newV = $this->getUnique($table, $field, $curData[$field], $uid, $curData['pid']);
8299
                    if ((string)$newV !== (string)$curData[$field]) {
8300
                        $newData[$field] = $newV;
8301
                    }
8302
                }
8303
            }
8304
        }
8305
        // IF there are changed fields, then update the database
8306
        if (!empty($newData)) {
8307
            $this->updateDB($table, $uid, $newData);
8308
        }
8309
    }
8310
8311
    /**
8312
     * Checks if any uniqueInSite eval fields are in the record and if so, they are re-written to be correct.
8313
     *
8314
     * @param string $table Table name
8315
     * @param int $uid Record UID
8316
     * @return bool whether the record had to be fixed or not
8317
     */
8318
    protected function fixUniqueInSite(string $table, int $uid): bool
8319
    {
8320
        $curData = $this->recordInfo($table, $uid, '*');
8321
        $workspaceId = $this->BE_USER->workspace;
8322
        $newData = [];
8323
        foreach ($GLOBALS['TCA'][$table]['columns'] as $field => $conf) {
8324
            if ($conf['config']['type'] === 'slug' && (string)$curData[$field] !== '') {
8325
                $evalCodesArray = GeneralUtility::trimExplode(',', $conf['config']['eval'], true);
8326
                if (in_array('uniqueInSite', $evalCodesArray, true)) {
8327
                    $helper = GeneralUtility::makeInstance(SlugHelper::class, $table, $field, $conf['config'], $workspaceId);
8328
                    $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

8328
                    $state = RecordStateFactory::forName($table)->fromArray(/** @scrutinizer ignore-type */ $curData);
Loading history...
8329
                    $newValue = $helper->buildSlugForUniqueInSite($curData[$field], $state);
8330
                    if ((string)$newValue !== (string)$curData[$field]) {
8331
                        $newData[$field] = $newValue;
8332
                    }
8333
                }
8334
            }
8335
        }
8336
        // IF there are changed fields, then update the database
8337
        if (!empty($newData)) {
8338
            $this->updateDB($table, $uid, $newData);
8339
            return true;
8340
        }
8341
        return false;
8342
    }
8343
8344
    /**
8345
     * Check if there are subpages that need an adoption as well
8346
     * @param int $pageId
8347
     */
8348
    protected function fixUniqueInSiteForSubpages(int $pageId)
8349
    {
8350
        // Get ALL subpages to update - read-permissions are respected
8351
        $subPages = $this->int_pageTreeInfo([], $pageId, 99, $pageId);
8352
        // Now fix uniqueInSite for subpages
8353
        foreach ($subPages as $thePageUid => $thePagePid) {
8354
            $recordWasModified = $this->fixUniqueInSite('pages', $thePageUid);
8355
            if ($recordWasModified) {
8356
                // @todo: Add logging and history - but how? we don't know the data that was in the system before
8357
            }
8358
        }
8359
    }
8360
8361
    /**
8362
     * When er record is copied you can specify fields from the previous record which should be copied into the new one
8363
     * 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)
8364
     *
8365
     * @param string $table Table name
8366
     * @param int $uid Record UID
8367
     * @param int $prevUid UID of previous record
8368
     * @param bool $update If set, updates the record
8369
     * @param array $newData Input array. If fields are already specified AND $update is not set, values are not set in output array.
8370
     * @return array Output array (For when the copying operation needs to get the information instead of updating the info)
8371
     * @internal should only be used from within DataHandler
8372
     */
8373
    public function fixCopyAfterDuplFields($table, $uid, $prevUid, $update, $newData = [])
8374
    {
8375
        if ($GLOBALS['TCA'][$table]['ctrl']['copyAfterDuplFields'] ?? false) {
8376
            $prevData = $this->recordInfo($table, $prevUid, '*');
8377
            $theFields = GeneralUtility::trimExplode(',', $GLOBALS['TCA'][$table]['ctrl']['copyAfterDuplFields'], true);
8378
            foreach ($theFields as $field) {
8379
                if ($GLOBALS['TCA'][$table]['columns'][$field] && ($update || !isset($newData[$field]))) {
8380
                    $newData[$field] = $prevData[$field];
8381
                }
8382
            }
8383
            if ($update && !empty($newData)) {
8384
                $this->updateDB($table, $uid, $newData);
8385
            }
8386
        }
8387
        return $newData;
8388
    }
8389
8390
    /**
8391
     * Casts a reference value. In case MM relations or foreign_field
8392
     * references are used. All other configurations, as well as
8393
     * foreign_table(!) could be stored as comma-separated-values
8394
     * as well. Since the system is not able to determine the default
8395
     * value automatically then, the TCA default value is used if
8396
     * it has been defined.
8397
     *
8398
     * @param int|string $value The value to be casted (e.g. '', '0', '1,2,3')
8399
     * @param array $configuration The TCA configuration of the accordant field
8400
     * @return int|string
8401
     */
8402
    protected function castReferenceValue($value, array $configuration)
8403
    {
8404
        if ((string)$value !== '') {
8405
            return $value;
8406
        }
8407
8408
        if (!empty($configuration['MM']) || !empty($configuration['foreign_field'])) {
8409
            return 0;
8410
        }
8411
8412
        if (array_key_exists('default', $configuration)) {
8413
            return $configuration['default'];
8414
        }
8415
8416
        return $value;
8417
    }
8418
8419
    /**
8420
     * Returns TRUE if the TCA/columns field type is a DB reference field
8421
     *
8422
     * @param array $conf Config array for TCA/columns field
8423
     * @return bool TRUE if DB reference field (group/db or select with foreign-table)
8424
     * @internal should only be used from within DataHandler
8425
     */
8426
    public function isReferenceField($conf)
8427
    {
8428
        if (!isset($conf['type'])) {
8429
            return false;
8430
        }
8431
8432
        return ($conf['type'] === 'group' && ($conf['internal_type'] ?? '') !== 'folder')
8433
            || (($conf['type'] === 'select' || $conf['type'] === 'category') && !empty($conf['foreign_table']));
8434
    }
8435
8436
    /**
8437
     * Returns the subtype as a string of an inline field.
8438
     * If it's not an inline field at all, it returns FALSE.
8439
     *
8440
     * @param array $conf Config array for TCA/columns field
8441
     * @return string|bool string Inline subtype (field|mm|list), boolean: FALSE
8442
     * @internal should only be used from within DataHandler
8443
     */
8444
    public function getInlineFieldType($conf)
8445
    {
8446
        if (empty($conf['type']) || $conf['type'] !== 'inline' || empty($conf['foreign_table'])) {
8447
            return false;
8448
        }
8449
        if ($conf['foreign_field'] ?? false) {
8450
            // The reference to the parent is stored in a pointer field in the child record
8451
            return 'field';
8452
        }
8453
        if ($conf['MM'] ?? false) {
8454
            // Regular MM intermediate table is used to store data
8455
            return 'mm';
8456
        }
8457
        // An item list (separated by comma) is stored (like select type is doing)
8458
        return 'list';
8459
    }
8460
8461
    /**
8462
     * Get modified header for a copied record
8463
     *
8464
     * @param string $table Table name
8465
     * @param int $pid PID value in which other records to test might be
8466
     * @param string $field Field name to get header value for.
8467
     * @param string $value Current field value
8468
     * @param int $count Counter (number of recursions)
8469
     * @param string $prevTitle Previous title we checked for (in previous recursion)
8470
     * @return string The field value, possibly appended with a "copy label
8471
     * @internal should only be used from within DataHandler
8472
     */
8473
    public function getCopyHeader($table, $pid, $field, $value, $count, $prevTitle = '')
8474
    {
8475
        // Set title value to check for:
8476
        $checkTitle = $value;
8477
        if ($count > 0) {
8478
            $checkTitle = $value . rtrim(' ' . sprintf($this->prependLabel($table), $count));
8479
        }
8480
        // Do check:
8481
        if ($prevTitle != $checkTitle || $count < 100) {
8482
            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8483
            $this->addDeleteRestriction($queryBuilder->getRestrictions()->removeAll());
8484
            $rowCount = $queryBuilder
8485
                ->count('uid')
8486
                ->from($table)
8487
                ->where(
8488
                    $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, \PDO::PARAM_INT)),
8489
                    $queryBuilder->expr()->eq($field, $queryBuilder->createNamedParameter($checkTitle, \PDO::PARAM_STR))
8490
                )
8491
                ->execute()
8492
                ->fetchOne();
8493
            if ($rowCount) {
8494
                return $this->getCopyHeader($table, $pid, $field, $value, $count + 1, $checkTitle);
8495
            }
8496
        }
8497
        // Default is to just return the current input title if no other was returned before:
8498
        return $checkTitle;
8499
    }
8500
8501
    /**
8502
     * Return "copy" label for a table. Although the name is "prepend" it actually APPENDs the label (after ...)
8503
     *
8504
     * @param string $table Table name
8505
     * @return string Label to append, containing "%s" for the number
8506
     * @see getCopyHeader()
8507
     * @internal should only be used from within DataHandler
8508
     */
8509
    public function prependLabel($table)
8510
    {
8511
        return $this->getLanguageService()->sL($GLOBALS['TCA'][$table]['ctrl']['prependAtCopy']);
8512
    }
8513
8514
    /**
8515
     * Get the final pid based on $table and $pid ($destPid type... pos/neg)
8516
     *
8517
     * @param string $table Table name
8518
     * @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!
8519
     * @return int
8520
     * @internal should only be used from within DataHandler
8521
     */
8522
    public function resolvePid($table, $pid)
8523
    {
8524
        $pid = (int)$pid;
8525
        if ($pid < 0) {
8526
            $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8527
            $query->getRestrictions()
8528
                ->removeAll();
8529
            $row = $query
8530
                ->select('pid')
8531
                ->from($table)
8532
                ->where($query->expr()->eq('uid', $query->createNamedParameter(abs($pid), \PDO::PARAM_INT)))
8533
                ->execute()
8534
                ->fetchAssociative();
8535
            $pid = (int)$row['pid'];
8536
        }
8537
        return $pid;
8538
    }
8539
8540
    /**
8541
     * Removes the prependAtCopy prefix on values
8542
     *
8543
     * @param string $table Table name
8544
     * @param string $value The value to fix
8545
     * @return string Clean name
8546
     * @internal should only be used from within DataHandler
8547
     */
8548
    public function clearPrefixFromValue($table, $value)
8549
    {
8550
        $regex = '/\s' . sprintf(preg_quote($this->prependLabel($table)), '[0-9]*') . '$/';
8551
        return @preg_replace($regex, '', $value);
8552
    }
8553
8554
    /**
8555
     * Check if there are records from tables on the pages to be deleted which the current user is not allowed to
8556
     *
8557
     * @param int[] $pageIds IDs of pages which should be checked
8558
     * @return string[]|null Return null, if permission granted, otherwise an array with the tables that are not allowed to be deleted
8559
     * @see canDeletePage()
8560
     */
8561
    protected function checkForRecordsFromDisallowedTables(array $pageIds): ?array
8562
    {
8563
        if ($this->admin) {
8564
            return null;
8565
        }
8566
8567
        $disallowedTables = [];
8568
        if (!empty($pageIds)) {
8569
            $tableNames = $this->compileAdminTables();
8570
            foreach ($tableNames as $table) {
8571
                $query = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
8572
                $query->getRestrictions()
8573
                    ->removeAll()
8574
                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8575
                $count = $query->count('uid')
8576
                    ->from($table)
8577
                    ->where($query->expr()->in(
8578
                        'pid',
8579
                        $query->createNamedParameter($pageIds, Connection::PARAM_INT_ARRAY)
8580
                    ))
8581
                    ->execute()
8582
                    ->fetchOne();
8583
                if ($count && ($this->tableReadOnly($table) || !$this->checkModifyAccessList($table))) {
8584
                    $disallowedTables[] = $table;
8585
                }
8586
            }
8587
        }
8588
        return !empty($disallowedTables) ? $disallowedTables : null;
8589
    }
8590
8591
    /**
8592
     * Determine if a record was copied or if a record is the result of a copy action.
8593
     *
8594
     * @param string $table The tablename of the record
8595
     * @param int $uid The uid of the record
8596
     * @return bool Returns TRUE if the record is copied or is the result of a copy action
8597
     * @internal should only be used from within DataHandler
8598
     */
8599
    public function isRecordCopied($table, $uid)
8600
    {
8601
        // If the record was copied:
8602
        if (isset($this->copyMappingArray[$table][$uid])) {
8603
            return true;
8604
        }
8605
        if (isset($this->copyMappingArray[$table]) && in_array($uid, array_values($this->copyMappingArray[$table]))) {
8606
            return true;
8607
        }
8608
        return false;
8609
    }
8610
8611
    /******************************
8612
     *
8613
     * Clearing cache
8614
     *
8615
     ******************************/
8616
8617
    /**
8618
     * Clearing the cache based on a page being updated
8619
     * If the $table is 'pages' then cache is cleared for all pages on the same level (and subsequent?)
8620
     * Else just clear the cache for the parent page of the record.
8621
     *
8622
     * @param string $table Table name of record that was just updated.
8623
     * @param int $uid UID of updated / inserted record
8624
     * @param int $pid REAL PID of page of a deleted/moved record to get TSconfig in ClearCache.
8625
     * @internal This method is not meant to be called directly but only from the core itself or from hooks
8626
     */
8627
    public function registerRecordIdForPageCacheClearing($table, $uid, $pid = null)
8628
    {
8629
        if (!is_array(static::$recordsToClearCacheFor[$table] ?? false)) {
8630
            static::$recordsToClearCacheFor[$table] = [];
8631
        }
8632
        static::$recordsToClearCacheFor[$table][] = (int)$uid;
8633
        if ($pid !== null) {
8634
            if (!isset(static::$recordPidsForDeletedRecords[$table]) || !is_array(static::$recordPidsForDeletedRecords[$table])) {
8635
                static::$recordPidsForDeletedRecords[$table] = [];
8636
            }
8637
            static::$recordPidsForDeletedRecords[$table][$uid][] = (int)$pid;
8638
        }
8639
    }
8640
8641
    /**
8642
     * Do the actual clear cache
8643
     */
8644
    protected function processClearCacheQueue()
8645
    {
8646
        $tagsToClear = [];
8647
        $clearCacheCommands = [];
8648
8649
        foreach (static::$recordsToClearCacheFor as $table => $uids) {
8650
            foreach (array_unique($uids) as $uid) {
8651
                if (!isset($GLOBALS['TCA'][$table]) || $uid <= 0) {
8652
                    return;
8653
                }
8654
                // For move commands we may get more then 1 parent.
8655
                $pageUids = $this->getOriginalParentOfRecord($table, $uid);
8656
                foreach ($pageUids as $originalParent) {
8657
                    [$tagsToClearFromPrepare, $clearCacheCommandsFromPrepare]
8658
                        = $this->prepareCacheFlush($table, $uid, $originalParent);
8659
                    $tagsToClear = array_merge($tagsToClear, $tagsToClearFromPrepare);
8660
                    $clearCacheCommands = array_merge($clearCacheCommands, $clearCacheCommandsFromPrepare);
8661
                }
8662
            }
8663
        }
8664
8665
        /** @var CacheManager $cacheManager */
8666
        $cacheManager = $this->getCacheManager();
8667
        $cacheManager->flushCachesInGroupByTags('pages', array_keys($tagsToClear));
8668
8669
        // Filter duplicate cache commands from cacheQueue
8670
        $clearCacheCommands = array_unique($clearCacheCommands);
8671
        // Execute collected clear cache commands from page TSConfig
8672
        foreach ($clearCacheCommands as $command) {
8673
            $this->clear_cacheCmd($command);
8674
        }
8675
8676
        // Reset the cache clearing array
8677
        static::$recordsToClearCacheFor = [];
8678
8679
        // Reset the original pid array
8680
        static::$recordPidsForDeletedRecords = [];
8681
    }
8682
8683
    /**
8684
     * Prepare the cache clearing
8685
     *
8686
     * @param string $table Table name of record that needs to be cleared
8687
     * @param int $uid UID of record for which the cache needs to be cleared
8688
     * @param int $pid Original pid of the page of the record which the cache needs to be cleared
8689
     * @return array Array with tagsToClear and clearCacheCommands
8690
     * @internal This function is internal only it may be changed/removed also in minor version numbers.
8691
     */
8692
    protected function prepareCacheFlush($table, $uid, $pid)
8693
    {
8694
        $tagsToClear = [];
8695
        $clearCacheCommands = [];
8696
        $pageUid = 0;
8697
        $clearCacheEnabled = true;
8698
        // Get Page TSconfig relevant:
8699
        $TSConfig = BackendUtility::getPagesTSconfig($pid)['TCEMAIN.'] ?? [];
8700
8701
        if (!empty($TSConfig['clearCache_disable'])) {
8702
            $clearCacheEnabled = false;
8703
        }
8704
8705
        if ($clearCacheEnabled && $this->BE_USER->workspace !== 0 && BackendUtility::isTableWorkspaceEnabled($table)) {
8706
            $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
8707
            $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
8708
            $queryBuilder->getRestrictions()
8709
                ->removeAll()
8710
                ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8711
            $count = $queryBuilder
8712
                ->count('uid')
8713
                ->from($table)
8714
                ->where(
8715
                    $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)),
8716
                    $queryBuilder->expr()->eq('t3ver_oid', 0)
8717
                )
8718
                ->execute()
8719
                ->fetchColumn();
8720
            if ($count === 0) {
8721
                $clearCacheEnabled = false;
8722
            }
8723
        }
8724
8725
        if ($clearCacheEnabled) {
8726
            $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
8727
            // If table is "pages":
8728
            $pageIdsThatNeedCacheFlush = [];
8729
            if ($table === 'pages') {
8730
                // Find out if the record is a get the original page
8731
                $pageUid = $this->getDefaultLanguagePageId($uid);
8732
8733
                // Builds list of pages on the SAME level as this page (siblings)
8734
                $queryBuilder = $connectionPool->getQueryBuilderForTable('pages');
8735
                $queryBuilder->getRestrictions()
8736
                    ->removeAll()
8737
                    ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8738
                $siblings = $queryBuilder
8739
                    ->select('A.pid AS pid', 'B.uid AS uid')
8740
                    ->from('pages', 'A')
8741
                    ->from('pages', 'B')
8742
                    ->where(
8743
                        $queryBuilder->expr()->eq('A.uid', $queryBuilder->createNamedParameter($pageUid, \PDO::PARAM_INT)),
8744
                        $queryBuilder->expr()->eq('B.pid', $queryBuilder->quoteIdentifier('A.pid')),
8745
                        $queryBuilder->expr()->gte('A.pid', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
8746
                    )
8747
                    ->execute();
8748
8749
                $parentPageId = 0;
8750
                while ($row_tmp = $siblings->fetchAssociative()) {
8751
                    $pageIdsThatNeedCacheFlush[] = (int)$row_tmp['uid'];
8752
                    $parentPageId = (int)$row_tmp['pid'];
8753
                    // Add children as well:
8754
                    if ($TSConfig['clearCache_pageSiblingChildren'] ?? false) {
8755
                        $siblingChildrenQuery = $connectionPool->getQueryBuilderForTable('pages');
8756
                        $siblingChildrenQuery->getRestrictions()
8757
                            ->removeAll()
8758
                            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8759
                        $siblingChildren = $siblingChildrenQuery
8760
                            ->select('uid')
8761
                            ->from('pages')
8762
                            ->where($siblingChildrenQuery->expr()->eq(
8763
                                'pid',
8764
                                $siblingChildrenQuery->createNamedParameter($row_tmp['uid'], \PDO::PARAM_INT)
8765
                            ))
8766
                            ->execute();
8767
                        while ($row_tmp2 = $siblingChildren->fetchAssociative()) {
8768
                            $pageIdsThatNeedCacheFlush[] = (int)$row_tmp2['uid'];
8769
                        }
8770
                    }
8771
                }
8772
                // Finally, add the parent page as well when clearing a specific page
8773
                if ($parentPageId > 0) {
8774
                    $pageIdsThatNeedCacheFlush[] = $parentPageId;
8775
                }
8776
                // Add grand-parent as well if configured
8777
                if ($TSConfig['clearCache_pageGrandParent'] ?? false) {
8778
                    $parentQuery = $connectionPool->getQueryBuilderForTable('pages');
8779
                    $parentQuery->getRestrictions()
8780
                        ->removeAll()
8781
                        ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8782
                    $row_tmp = $parentQuery
8783
                        ->select('pid')
8784
                        ->from('pages')
8785
                        ->where($parentQuery->expr()->eq(
8786
                            'uid',
8787
                            $parentQuery->createNamedParameter($parentPageId, \PDO::PARAM_INT)
8788
                        ))
8789
                        ->execute()
8790
                        ->fetchAssociative();
8791
                    if (!empty($row_tmp)) {
8792
                        $pageIdsThatNeedCacheFlush[] = (int)$row_tmp['pid'];
8793
                    }
8794
                }
8795
            } else {
8796
                // For other tables than "pages", delete cache for the records "parent page".
8797
                $pageIdsThatNeedCacheFlush[] = $pageUid = (int)$this->getPID($table, $uid);
8798
                // Add the parent page as well
8799
                if ($TSConfig['clearCache_pageGrandParent'] ?? false) {
8800
                    $parentQuery = $connectionPool->getQueryBuilderForTable('pages');
8801
                    $parentQuery->getRestrictions()
8802
                        ->removeAll()
8803
                        ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
8804
                    $parentPageRecord = $parentQuery
8805
                        ->select('pid')
8806
                        ->from('pages')
8807
                        ->where($parentQuery->expr()->eq(
8808
                            'uid',
8809
                            $parentQuery->createNamedParameter($pageUid, \PDO::PARAM_INT)
8810
                        ))
8811
                        ->execute()
8812
                        ->fetchAssociative();
8813
                    if (!empty($parentPageRecord)) {
8814
                        $pageIdsThatNeedCacheFlush[] = (int)$parentPageRecord['pid'];
8815
                    }
8816
                }
8817
            }
8818
            // Call pre-processing function for clearing of cache for page ids:
8819
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearPageCacheEval'] ?? [] as $funcName) {
8820
                $_params = ['pageIdArray' => &$pageIdsThatNeedCacheFlush, 'table' => $table, 'uid' => $uid, 'functionID' => 'clear_cache()'];
8821
                // Returns the array of ids to clear, FALSE if nothing should be cleared! Never an empty array!
8822
                GeneralUtility::callUserFunction($funcName, $_params, $this);
8823
            }
8824
            // Delete cache for selected pages:
8825
            foreach ($pageIdsThatNeedCacheFlush as $pageId) {
8826
                $tagsToClear['pageId_' . $pageId] = true;
8827
            }
8828
            // Queue delete cache for current table and record
8829
            $tagsToClear[$table] = true;
8830
            $tagsToClear[$table . '_' . $uid] = true;
8831
        }
8832
        // Clear cache for pages entered in TSconfig:
8833
        if (!empty($TSConfig['clearCacheCmd'])) {
8834
            $commands = GeneralUtility::trimExplode(',', $TSConfig['clearCacheCmd'], true);
8835
            $clearCacheCommands = array_unique($commands);
8836
        }
8837
        // Call post processing function for clear-cache:
8838
        $_params = ['table' => $table, 'uid' => $uid, 'uid_page' => $pageUid, 'TSConfig' => $TSConfig, 'tags' => $tagsToClear, 'clearCacheEnabled' => $clearCacheEnabled];
8839
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] ?? [] as $_funcRef) {
8840
            GeneralUtility::callUserFunction($_funcRef, $_params, $this);
8841
        }
8842
        return [
8843
            $tagsToClear,
8844
            $clearCacheCommands,
8845
        ];
8846
    }
8847
8848
    /**
8849
     * Clears the cache based on the command $cacheCmd.
8850
     *
8851
     * $cacheCmd='pages'
8852
     * Clears cache for all pages and page-based caches inside the cache manager.
8853
     * Requires admin-flag to be set for BE_USER.
8854
     *
8855
     * $cacheCmd='all'
8856
     * Clears all cache_tables. This is necessary if templates are updated.
8857
     * Requires admin-flag to be set for BE_USER.
8858
     *
8859
     * The following cache_* are intentionally not cleared by 'all'
8860
     *
8861
     * - imagesizes:	Clearing this table would cause a lot of unneeded
8862
     * Imagemagick calls because the size information has
8863
     * to be fetched again after clearing.
8864
     * - all caches inside the cache manager that are inside the group "system"
8865
     * - they are only needed to build up the core system and templates.
8866
     *   If the group of system caches needs to be deleted explicitly, use
8867
     *   flushCachesInGroup('system') of CacheManager directly.
8868
     *
8869
     * $cacheCmd=[integer]
8870
     * Clears cache for the page pointed to by $cacheCmd (an integer).
8871
     *
8872
     * $cacheCmd='cacheTag:[string]'
8873
     * Flush page and pagesection cache by given tag
8874
     *
8875
     * $cacheCmd='cacheId:[string]'
8876
     * Removes cache identifier from page and page section cache
8877
     *
8878
     * Can call a list of post processing functions as defined in
8879
     * $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']
8880
     * (numeric array with values being the function references, called by
8881
     * GeneralUtility::callUserFunction()).
8882
     *
8883
     *
8884
     * @param string $cacheCmd The cache command, see above description
8885
     */
8886
    public function clear_cacheCmd($cacheCmd)
8887
    {
8888
        if (is_object($this->BE_USER)) {
8889
            $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]);
8890
        }
8891
        $userTsConfig = $this->BE_USER->getTSConfig();
8892
        switch (strtolower($cacheCmd)) {
8893
            case 'pages':
8894
                if ($this->admin || ($userTsConfig['options.']['clearCache.']['pages'] ?? false)) {
8895
                    $this->getCacheManager()->flushCachesInGroup('pages');
8896
                }
8897
                break;
8898
            case 'all':
8899
                // allow to clear all caches if the TS config option is enabled or the option is not explicitly
8900
                // disabled for admins (which could clear all caches by default). The latter option is useful
8901
                // for big production sites where it should be possible to restrict the cache clearing for some admins.
8902
                if (($userTsConfig['options.']['clearCache.']['all'] ?? false)
8903
                    || ($this->admin && (bool)($userTsConfig['options.']['clearCache.']['all'] ?? true))
8904
                ) {
8905
                    $this->getCacheManager()->flushCaches();
8906
                    GeneralUtility::makeInstance(ConnectionPool::class)
8907
                        ->getConnectionForTable('cache_treelist')
8908
                        ->truncate('cache_treelist');
8909
8910
                    // Delete Opcode Cache
8911
                    GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive();
8912
                }
8913
                break;
8914
        }
8915
8916
        $tagsToFlush = [];
8917
        // Clear cache for a page ID!
8918
        if (MathUtility::canBeInterpretedAsInteger($cacheCmd)) {
8919
            $list_cache = [$cacheCmd];
8920
            // Call pre-processing function for clearing of cache for page ids:
8921
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearPageCacheEval'] ?? [] as $funcName) {
8922
                $_params = ['pageIdArray' => &$list_cache, 'cacheCmd' => $cacheCmd, 'functionID' => 'clear_cacheCmd()'];
8923
                // Returns the array of ids to clear, FALSE if nothing should be cleared! Never an empty array!
8924
                GeneralUtility::callUserFunction($funcName, $_params, $this);
8925
            }
8926
            // Delete cache for selected pages:
8927
            if (is_array($list_cache)) {
0 ignored issues
show
introduced by
The condition is_array($list_cache) is always true.
Loading history...
8928
                foreach ($list_cache as $pageId) {
8929
                    $tagsToFlush[] = 'pageId_' . (int)$pageId;
8930
                }
8931
            }
8932
        }
8933
        // flush cache by tag
8934
        if (str_starts_with(strtolower($cacheCmd), 'cachetag:')) {
8935
            $cacheTag = substr($cacheCmd, 9);
8936
            $tagsToFlush[] = $cacheTag;
8937
        }
8938
        // process caching framework operations
8939
        if (!empty($tagsToFlush)) {
8940
            $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

8940
            $this->/** @scrutinizer ignore-call */ 
8941
                   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...
8941
        }
8942
8943
        // Call post processing function for clear-cache:
8944
        $_params = ['cacheCmd' => strtolower($cacheCmd), 'tags' => $tagsToFlush];
8945
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'] ?? [] as $_funcRef) {
8946
            GeneralUtility::callUserFunction($_funcRef, $_params, $this);
8947
        }
8948
    }
8949
8950
    /*****************************
8951
     *
8952
     * Logging
8953
     *
8954
     *****************************/
8955
    /**
8956
     * Logging actions from DataHandler
8957
     *
8958
     * @param string $table Table name the log entry is concerned with. Blank if NA
8959
     * @param int $recuid Record UID. Zero if NA
8960
     * @param int $action Action number: 0=No category, 1=new record, 2=update record, 3= delete record, 4= move record, 5= Check/evaluate
8961
     * @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
8962
     * @param int $error The severity: 0 = message, 1 = error, 2 = System Error, 3 = security notice (admin), 4 warning
8963
     * @param string $details Default error message in english
8964
     * @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
8965
     * @param array $data Array with special information that may go into $details by '%s' marks / sprintf() when the log is shown
8966
     * @param int $event_pid The page_uid (pid) where the event occurred. Used to select log-content for specific pages.
8967
     * @param string $NEWid NEW id for new records
8968
     * @return int Log entry UID (0 if no log entry was written or logging is disabled)
8969
     * @see \TYPO3\CMS\Core\SysLog\Action\Database for all available values of argument $action
8970
     * @see \TYPO3\CMS\Core\SysLog\Error for all available values of argument $error
8971
     * @internal should only be used from within TYPO3 Core
8972
     */
8973
    public function log($table, $recuid, $action, $recpid, $error, $details, $details_nr = -1, $data = [], $event_pid = -1, $NEWid = '')
8974
    {
8975
        if (!$this->enableLogging) {
8976
            return 0;
8977
        }
8978
        // Type value for DataHandler
8979
        if (!$this->storeLogMessages) {
8980
            $details = '';
8981
        }
8982
        if ($error > 0) {
8983
            $detailMessage = $details;
8984
            if (is_array($data)) {
0 ignored issues
show
introduced by
The condition is_array($data) is always true.
Loading history...
8985
                $detailMessage = vsprintf($details, $data);
8986
            }
8987
            $this->errorLog[] = '[' . SystemLogType::DB . '.' . $action . '.' . $details_nr . ']: ' . $detailMessage;
8988
        }
8989
        return $this->BE_USER->writelog(SystemLogType::DB, $action, $error, $details_nr, $details, $data, $table, $recuid, $recpid, $event_pid, $NEWid);
8990
    }
8991
8992
    /**
8993
     * Print log error messages from the operations of this script instance
8994
     * @internal should only be used from within TYPO3 Core
8995
     */
8996
    public function printLogErrorMessages()
8997
    {
8998
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
8999
        $queryBuilder->getRestrictions()->removeAll();
9000
        $result = $queryBuilder
9001
            ->select('*')
9002
            ->from('sys_log')
9003
            ->where(
9004
                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::DB, \PDO::PARAM_INT)),
9005
                $queryBuilder->expr()->eq(
9006
                    'userid',
9007
                    $queryBuilder->createNamedParameter($this->BE_USER->user['uid'], \PDO::PARAM_INT)
9008
                ),
9009
                $queryBuilder->expr()->eq(
9010
                    'tstamp',
9011
                    $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
9012
                ),
9013
                $queryBuilder->expr()->neq('error', $queryBuilder->createNamedParameter(SystemLogErrorClassification::MESSAGE, \PDO::PARAM_INT))
9014
            )
9015
            ->execute();
9016
9017
        while ($row = $result->fetchAssociative()) {
9018
            $log_data = unserialize($row['log_data'], ['allowed_classes' => false]) ?: [];
9019
            $msg = $row['error'] . ': ' . sprintf($row['details'], $log_data[0] ?? '', $log_data[1] ?? '', $log_data[2] ?? '', $log_data[3] ?? '', $log_data[4] ?? '');
9020
            /** @var FlashMessage $flashMessage */
9021
            $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $msg, '', $row['error'] === SystemLogErrorClassification::WARNING ? FlashMessage::WARNING : FlashMessage::ERROR, true);
9022
            /** @var FlashMessageService $flashMessageService */
9023
            $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class);
9024
            $defaultFlashMessageQueue = $flashMessageService->getMessageQueueByIdentifier();
9025
            $defaultFlashMessageQueue->enqueue($flashMessage);
9026
        }
9027
    }
9028
9029
    /*****************************
9030
     *
9031
     * Internal (do not use outside Core!)
9032
     *
9033
     *****************************/
9034
9035
    /**
9036
     * Find out if the record is a localization. If so, get the uid of the default language page.
9037
     * Always returns the uid of the workspace live record: No explicit workspace overlay is applied.
9038
     *
9039
     * @param int $pageId Page UID, can be the default page record, or a page translation record ID
9040
     * @return int UID of the default page record in live workspace
9041
     */
9042
    protected function getDefaultLanguagePageId(int $pageId): int
9043
    {
9044
        $localizationParentFieldName = $GLOBALS['TCA']['pages']['ctrl']['transOrigPointerField'];
9045
        $row = $this->recordInfo('pages', $pageId, $localizationParentFieldName);
9046
        $localizationParent = (int)($row[$localizationParentFieldName] ?? 0);
9047
        if ($localizationParent > 0) {
9048
            return $localizationParent;
9049
        }
9050
        return $pageId;
9051
    }
9052
9053
    /**
9054
     * Preprocesses field array based on field type. Some fields must be adjusted
9055
     * before going to database. This is done on the copy of the field array because
9056
     * original values are used in remap action later.
9057
     *
9058
     * @param string $table	Table name
9059
     * @param array $fieldArray	Field array to check
9060
     * @return array Updated field array
9061
     * @internal should only be used from within TYPO3 Core
9062
     */
9063
    public function insertUpdateDB_preprocessBasedOnFieldType($table, $fieldArray)
9064
    {
9065
        $result = $fieldArray;
9066
        foreach ($fieldArray as $field => $value) {
9067
            if (!MathUtility::canBeInterpretedAsInteger($value)
9068
                && isset($GLOBALS['TCA'][$table]['columns'][$field]['config']['type'])
9069
                && $GLOBALS['TCA'][$table]['columns'][$field]['config']['type'] === 'inline'
9070
                && ($GLOBALS['TCA'][$table]['columns'][$field]['config']['foreign_field'] ?? false)
9071
            ) {
9072
                $result[$field] = count(GeneralUtility::trimExplode(',', $value, true));
9073
            }
9074
        }
9075
        return $result;
9076
    }
9077
9078
    /**
9079
     * Determines whether a particular record has been deleted
9080
     * using DataHandler::deleteRecord() in this instance.
9081
     *
9082
     * @param string $tableName
9083
     * @param int $uid
9084
     * @return bool
9085
     * @internal should only be used from within TYPO3 Core
9086
     */
9087
    public function hasDeletedRecord($tableName, $uid)
9088
    {
9089
        return
9090
            !empty($this->deletedRecords[$tableName])
9091
            && in_array($uid, $this->deletedRecords[$tableName])
9092
        ;
9093
    }
9094
9095
    /**
9096
     * Gets the automatically versionized id of a record.
9097
     *
9098
     * @param string $table Name of the table
9099
     * @param int $id Uid of the record
9100
     * @return int|null
9101
     * @internal should only be used from within TYPO3 Core
9102
     */
9103
    public function getAutoVersionId($table, $id): ?int
9104
    {
9105
        $result = null;
9106
        if (isset($this->autoVersionIdMap[$table][$id])) {
9107
            $result = (int)trim($this->autoVersionIdMap[$table][$id]);
9108
        }
9109
        return $result;
9110
    }
9111
9112
    /**
9113
     * Overlays the automatically versionized id of a record.
9114
     *
9115
     * @param string $table Name of the table
9116
     * @param int $id Uid of the record
9117
     * @return int
9118
     */
9119
    protected function overlayAutoVersionId($table, $id)
9120
    {
9121
        $autoVersionId = $this->getAutoVersionId($table, $id);
9122
        if ($autoVersionId !== null) {
9123
            $id = $autoVersionId;
9124
        }
9125
        return $id;
9126
    }
9127
9128
    /**
9129
     * Adds new values to the remapStackChildIds array.
9130
     *
9131
     * @param array $idValues uid values
9132
     */
9133
    protected function addNewValuesToRemapStackChildIds(array $idValues)
9134
    {
9135
        foreach ($idValues as $idValue) {
9136
            if (strpos($idValue, 'NEW') === 0) {
9137
                $this->remapStackChildIds[$idValue] = true;
9138
            }
9139
        }
9140
    }
9141
9142
    /**
9143
     * Resolves versioned records for the current workspace scope.
9144
     * Delete placeholders are substituted and removed.
9145
     *
9146
     * @param string $tableName Name of the table to be processed
9147
     * @param string $fieldNames List of the field names to be fetched
9148
     * @param string $sortingField Name of the sorting field to be used
9149
     * @param array $liveIds Flat array of (live) record ids
9150
     * @return array
9151
     */
9152
    protected function resolveVersionedRecords($tableName, $fieldNames, $sortingField, array $liveIds)
9153
    {
9154
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)
9155
            ->getConnectionForTable($tableName);
9156
        $sortingStatement = !empty($sortingField)
9157
            ? [$connection->quoteIdentifier($sortingField)]
9158
            : null;
9159
        /** @var PlainDataResolver $resolver */
9160
        $resolver = GeneralUtility::makeInstance(
9161
            PlainDataResolver::class,
9162
            $tableName,
9163
            $liveIds,
9164
            $sortingStatement
9165
        );
9166
9167
        $resolver->setWorkspaceId($this->BE_USER->workspace);
9168
        $resolver->setKeepDeletePlaceholder(false);
9169
        $resolver->setKeepMovePlaceholder(false);
9170
        $resolver->setKeepLiveIds(true);
9171
        $recordIds = $resolver->get();
9172
9173
        $records = [];
9174
        foreach ($recordIds as $recordId) {
9175
            $records[$recordId] = BackendUtility::getRecord($tableName, $recordId, $fieldNames);
9176
        }
9177
9178
        return $records;
9179
    }
9180
9181
    /**
9182
     * Evaluates if auto creation of a version of a record is allowed.
9183
     * Auto-creation of version: In offline workspace, test if versioning is
9184
     * enabled and look for workspace version of input record.
9185
     * If there is no versionized record found we will create one and save to that.
9186
     *
9187
     * @param string $table Table of the record
9188
     * @param int $id UID of record
9189
     * @param int|null $recpid PID of record
9190
     * @return bool TRUE if ok.
9191
     * @internal should only be used from within TYPO3 Core
9192
     */
9193
    protected function workspaceAllowAutoCreation(string $table, $id, $recpid): bool
9194
    {
9195
        // No version can be created in live workspace
9196
        if ($this->BE_USER->workspace === 0) {
9197
            return false;
9198
        }
9199
        // No versioning support for this table, so no version can be created
9200
        if (!BackendUtility::isTableWorkspaceEnabled($table)) {
9201
            return false;
9202
        }
9203
        if ($recpid < 0) {
9204
            return false;
9205
        }
9206
        // There must be no existing version of this record in workspace
9207
        if (BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid')) {
9208
            return false;
9209
        }
9210
        return true;
9211
    }
9212
9213
    /**
9214
     * Evaluates if a user is allowed to edit the offline version
9215
     *
9216
     * @param string $table Table of record
9217
     * @param array $record array where fields are at least: pid, t3ver_wsid, t3ver_stage (if versioningWS is set)
9218
     * @return string String error code, telling the failure state. FALSE=All ok
9219
     * @see workspaceCannotEditRecord()
9220
     * @internal this method will be moved to EXT:workspaces
9221
     */
9222
    public function workspaceCannotEditOfflineVersion(string $table, array $record)
9223
    {
9224
        if (!BackendUtility::isTableWorkspaceEnabled($table)) {
9225
            return 'Table does not support versioning.';
9226
        }
9227
        $versionState = new VersionState($record['t3ver_state']);
9228
        if ($versionState->equals(VersionState::NEW_PLACEHOLDER) || (int)$record['t3ver_oid'] > 0) {
9229
            return $this->workspaceCannotEditRecord($table, $record);
9230
        }
9231
        return 'Not an offline version';
9232
    }
9233
9234
    /**
9235
     * Checking if editing of an existing record is allowed in current workspace if that is offline.
9236
     * Rules for editing in offline mode:
9237
     * - record supports versioning and is an offline version from workspace and has the current stage
9238
     * - or record (any) is in a branch where there is a page which is a version from the workspace
9239
     *   and where the stage is not preventing records
9240
     *
9241
     * @param string $table Table of record
9242
     * @param array|int $recData Integer (record uid) or array where fields are at least: pid, t3ver_wsid, t3ver_oid, t3ver_stage (if versioningWS is set)
9243
     * @return string String error code, telling the failure state. FALSE=All ok
9244
     * @internal should only be used from within TYPO3 Core
9245
     */
9246
    public function workspaceCannotEditRecord($table, $recData)
9247
    {
9248
        // Only test if the user is in a workspace
9249
        if ($this->BE_USER->workspace === 0) {
9250
            return false;
9251
        }
9252
        $tableSupportsVersioning = BackendUtility::isTableWorkspaceEnabled($table);
9253
        if (!is_array($recData)) {
9254
            $recData = BackendUtility::getRecord(
9255
                $table,
9256
                $recData,
9257
                'pid' . ($tableSupportsVersioning ? ',t3ver_oid,t3ver_wsid,t3ver_state,t3ver_stage' : '')
9258
            );
9259
        }
9260
        if (is_array($recData)) {
9261
            // We are testing a "version" (identified by having a t3ver_oid): it can be edited provided
9262
            // that workspace matches and versioning is enabled for the table.
9263
            $versionState = new VersionState($recData['t3ver_state'] ?? 0);
9264
            if ($tableSupportsVersioning
9265
                && (
9266
                    $versionState->equals(VersionState::NEW_PLACEHOLDER) || (int)(($recData['t3ver_oid'] ?? 0) > 0)
9267
                )
9268
            ) {
9269
                if ((int)$recData['t3ver_wsid'] !== $this->BE_USER->workspace) {
9270
                    // So does workspace match?
9271
                    return 'Workspace ID of record didn\'t match current workspace';
9272
                }
9273
                // So is the user allowed to "use" the edit stage within the workspace?
9274
                return $this->BE_USER->workspaceCheckStageForCurrent(0)
9275
                    ? false
9276
                    : 'User\'s access level did not allow for editing';
9277
            }
9278
            // Check if we are testing a "live" record
9279
            if ($this->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
9280
                // Live records are OK in the current workspace
9281
                return false;
9282
            }
9283
            // If not offline, output error
9284
            return 'Online record was not in a workspace!';
9285
        }
9286
        return 'No record';
9287
    }
9288
9289
    /**
9290
     * Gets the outer most instance of \TYPO3\CMS\Core\DataHandling\DataHandler
9291
     * Since \TYPO3\CMS\Core\DataHandling\DataHandler can create nested objects of itself,
9292
     * this method helps to determine the first (= outer most) one.
9293
     *
9294
     * @return DataHandler
9295
     */
9296
    protected function getOuterMostInstance()
9297
    {
9298
        if (!isset($this->outerMostInstance)) {
9299
            $stack = array_reverse(debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS));
9300
            foreach ($stack as $stackItem) {
9301
                if (isset($stackItem['object']) && $stackItem['object'] instanceof self) {
9302
                    $this->outerMostInstance = $stackItem['object'];
9303
                    break;
9304
                }
9305
            }
9306
        }
9307
        return $this->outerMostInstance;
9308
    }
9309
9310
    /**
9311
     * Determines whether the this object is the outer most instance of itself
9312
     * Since DataHandler can create nested objects of itself,
9313
     * this method helps to determine the first (= outer most) one.
9314
     *
9315
     * @return bool
9316
     */
9317
    public function isOuterMostInstance()
9318
    {
9319
        return $this->getOuterMostInstance() === $this;
9320
    }
9321
9322
    /**
9323
     * Gets an instance of the runtime cache.
9324
     *
9325
     * @return FrontendInterface
9326
     */
9327
    protected function getRuntimeCache()
9328
    {
9329
        return $this->getCacheManager()->getCache('runtime');
9330
    }
9331
9332
    /**
9333
     * Determines nested element calls.
9334
     *
9335
     * @param string $table Name of the table
9336
     * @param int $id Uid of the record
9337
     * @param string $identifier Name of the action to be checked
9338
     * @return bool
9339
     */
9340
    protected function isNestedElementCallRegistered($table, $id, $identifier)
9341
    {
9342
        $nestedElementCalls = (array)$this->runtimeCache->get($this->cachePrefixNestedElementCalls);
9343
        return isset($nestedElementCalls[$identifier][$table][$id]);
9344
    }
9345
9346
    /**
9347
     * Registers nested elements calls.
9348
     * This is used to track nested calls (e.g. for following m:n relations).
9349
     *
9350
     * @param string $table Name of the table
9351
     * @param int $id Uid of the record
9352
     * @param string $identifier Name of the action to be tracked
9353
     */
9354
    protected function registerNestedElementCall($table, $id, $identifier)
9355
    {
9356
        $nestedElementCalls = (array)$this->runtimeCache->get($this->cachePrefixNestedElementCalls);
9357
        $nestedElementCalls[$identifier][$table][$id] = true;
9358
        $this->runtimeCache->set($this->cachePrefixNestedElementCalls, $nestedElementCalls);
9359
    }
9360
9361
    /**
9362
     * Resets the nested element calls.
9363
     */
9364
    protected function resetNestedElementCalls()
9365
    {
9366
        $this->runtimeCache->remove($this->cachePrefixNestedElementCalls);
9367
    }
9368
9369
    /**
9370
     * Determines whether an element was registered to be deleted in the registry.
9371
     *
9372
     * @param string $table Name of the table
9373
     * @param int $id Uid of the record
9374
     * @return bool
9375
     * @see registerElementsToBeDeleted
9376
     * @see resetElementsToBeDeleted
9377
     * @see copyRecord_raw
9378
     * @see versionizeRecord
9379
     */
9380
    protected function isElementToBeDeleted($table, $id)
9381
    {
9382
        $elementsToBeDeleted = (array)$this->runtimeCache->get('core-datahandler-elementsToBeDeleted');
9383
        return isset($elementsToBeDeleted[$table][$id]);
9384
    }
9385
9386
    /**
9387
     * Registers elements to be deleted in the registry.
9388
     *
9389
     * @see process_datamap
9390
     */
9391
    protected function registerElementsToBeDeleted()
9392
    {
9393
        $elementsToBeDeleted = (array)$this->runtimeCache->get('core-datahandler-elementsToBeDeleted');
9394
        $this->runtimeCache->set('core-datahandler-elementsToBeDeleted', array_merge($elementsToBeDeleted, $this->getCommandMapElements('delete')));
9395
    }
9396
9397
    /**
9398
     * Resets the elements to be deleted in the registry.
9399
     *
9400
     * @see process_datamap
9401
     */
9402
    protected function resetElementsToBeDeleted()
9403
    {
9404
        $this->runtimeCache->remove('core-datahandler-elementsToBeDeleted');
9405
    }
9406
9407
    /**
9408
     * Unsets elements (e.g. of the data map) that shall be deleted.
9409
     * This avoids to modify records that will be deleted later on.
9410
     *
9411
     * @param array $elements Elements to be modified
9412
     * @return array
9413
     */
9414
    protected function unsetElementsToBeDeleted(array $elements)
9415
    {
9416
        $elements = ArrayUtility::arrayDiffKeyRecursive($elements, $this->getCommandMapElements('delete'));
9417
        foreach ($elements as $key => $value) {
9418
            if (empty($value)) {
9419
                unset($elements[$key]);
9420
            }
9421
        }
9422
        return $elements;
9423
    }
9424
9425
    /**
9426
     * Gets elements of the command map that match a particular command.
9427
     *
9428
     * @param string $needle The command to be matched
9429
     * @return array
9430
     */
9431
    protected function getCommandMapElements($needle)
9432
    {
9433
        $elements = [];
9434
        foreach ($this->cmdmap as $tableName => $idArray) {
9435
            foreach ($idArray as $id => $commandArray) {
9436
                foreach ($commandArray as $command => $value) {
9437
                    if ($value && $command == $needle) {
9438
                        $elements[$tableName][$id] = true;
9439
                    }
9440
                }
9441
            }
9442
        }
9443
        return $elements;
9444
    }
9445
9446
    /**
9447
     * Controls active elements and sets NULL values if not active.
9448
     * Datamap is modified accordant to submitted control values.
9449
     */
9450
    protected function controlActiveElements()
9451
    {
9452
        if (!empty($this->control['active'])) {
9453
            $this->setNullValues(
9454
                $this->control['active'],
9455
                $this->datamap
9456
            );
9457
        }
9458
    }
9459
9460
    /**
9461
     * Sets NULL values in haystack array.
9462
     * The general behaviour in the user interface is to enable/activate fields.
9463
     * Thus, this method uses NULL as value to be stored if a field is not active.
9464
     *
9465
     * @param array $active hierarchical array with active elements
9466
     * @param array $haystack hierarchical array with haystack to be modified
9467
     */
9468
    protected function setNullValues(array $active, array &$haystack)
9469
    {
9470
        foreach ($active as $key => $value) {
9471
            // Nested data is processes recursively
9472
            if (is_array($value)) {
9473
                $this->setNullValues(
9474
                    $value,
9475
                    $haystack[$key]
9476
                );
9477
            } elseif ($value == 0) {
9478
                // Field has not been activated in the user interface,
9479
                // thus a NULL value shall be stored in the database
9480
                $haystack[$key] = null;
9481
            }
9482
        }
9483
    }
9484
9485
    /**
9486
     * @param CorrelationId $correlationId
9487
     */
9488
    public function setCorrelationId(CorrelationId $correlationId): void
9489
    {
9490
        $this->correlationId = $correlationId;
9491
    }
9492
9493
    /**
9494
     * @return CorrelationId|null
9495
     */
9496
    public function getCorrelationId(): ?CorrelationId
9497
    {
9498
        return $this->correlationId;
9499
    }
9500
9501
    /**
9502
     * Entry point to post process a database insert. Currently bails early unless a UID has been forced
9503
     * and the database platform is not MySQL.
9504
     *
9505
     * @param \TYPO3\CMS\Core\Database\Connection $connection
9506
     * @param string $tableName
9507
     * @param int $suggestedUid
9508
     * @return int
9509
     */
9510
    protected function postProcessDatabaseInsert(Connection $connection, string $tableName, int $suggestedUid): int
9511
    {
9512
        if ($suggestedUid !== 0 && $connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
9513
            $this->postProcessPostgresqlInsert($connection, $tableName);
9514
            // The last inserted id on postgresql is actually the last value generated by the sequence.
9515
            // On a forced UID insert this might not be the actual value or the sequence might not even
9516
            // have generated a value yet.
9517
            // Return the actual ID we forced on insert as a surrogate.
9518
            return $suggestedUid;
9519
        }
9520
        if ($connection->getDatabasePlatform() instanceof SQLServerPlatform) {
9521
            return $this->postProcessSqlServerInsert($connection, $tableName);
9522
        }
9523
        $id = $connection->lastInsertId($tableName);
9524
        return (int)$id;
9525
    }
9526
9527
    /**
9528
     * Get the last insert ID from sql server
9529
     *
9530
     * - first checks whether doctrine might be able to fetch the ID from the
9531
     * sequence table
9532
     * - if that does not succeed it manually selects the current IDENTITY value
9533
     * from a table
9534
     * - returns 0 if both fail
9535
     *
9536
     * @param \TYPO3\CMS\Core\Database\Connection $connection
9537
     * @param string $tableName
9538
     * @return int
9539
     * @throws \Doctrine\DBAL\Exception
9540
     */
9541
    protected function postProcessSqlServerInsert(Connection $connection, string $tableName): int
9542
    {
9543
        $id = $connection->lastInsertId($tableName);
9544
        if (!((int)$id > 0)) {
9545
            $table = $connection->quoteIdentifier($tableName);
9546
            $result = $connection->executeQuery('SELECT IDENT_CURRENT(\'' . $table . '\') AS id')->fetchAssociative();
9547
            if (isset($result['id']) && $result['id'] > 0) {
9548
                $id = $result['id'];
9549
            }
9550
        }
9551
        return (int)$id;
9552
    }
9553
9554
    /**
9555
     * PostgreSQL works with sequences for auto increment columns. A sequence is not updated when a value is
9556
     * written to such a column. To avoid clashes when the sequence returns an existing ID this helper will
9557
     * update the sequence to the current max value of the column.
9558
     *
9559
     * @param \TYPO3\CMS\Core\Database\Connection $connection
9560
     * @param string $tableName
9561
     */
9562
    protected function postProcessPostgresqlInsert(Connection $connection, string $tableName)
9563
    {
9564
        $queryBuilder = $connection->createQueryBuilder();
9565
        $queryBuilder->getRestrictions()->removeAll();
9566
        $row = $queryBuilder->select('PGT.schemaname', 'S.relname', 'C.attname', 'T.relname AS tablename')
9567
            ->from('pg_class', 'S')
9568
            ->from('pg_depend', 'D')
9569
            ->from('pg_class', 'T')
9570
            ->from('pg_attribute', 'C')
9571
            ->from('pg_tables', 'PGT')
9572
            ->where(
9573
                $queryBuilder->expr()->eq('S.relkind', $queryBuilder->quote('S')),
9574
                $queryBuilder->expr()->eq('S.oid', $queryBuilder->quoteIdentifier('D.objid')),
9575
                $queryBuilder->expr()->eq('D.refobjid', $queryBuilder->quoteIdentifier('T.oid')),
9576
                $queryBuilder->expr()->eq('D.refobjid', $queryBuilder->quoteIdentifier('C.attrelid')),
9577
                $queryBuilder->expr()->eq('D.refobjsubid', $queryBuilder->quoteIdentifier('C.attnum')),
9578
                $queryBuilder->expr()->eq('T.relname', $queryBuilder->quoteIdentifier('PGT.tablename')),
9579
                $queryBuilder->expr()->eq('PGT.tablename', $queryBuilder->quote($tableName))
9580
            )
9581
            ->setMaxResults(1)
9582
            ->execute()
9583
            ->fetchAssociative();
9584
9585
        if ($row !== false) {
9586
            $connection->exec(
0 ignored issues
show
Deprecated Code introduced by
The function Doctrine\DBAL\Connection::exec() has been deprecated: Use {@link executeStatement()} instead. ( Ignorable by Annotation )

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

9586
            /** @scrutinizer ignore-deprecated */ $connection->exec(

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
9587
                sprintf(
9588
                    'SELECT SETVAL(%s, COALESCE(MAX(%s), 0)+1, FALSE) FROM %s',
9589
                    $connection->quote($row['schemaname'] . '.' . $row['relname']),
9590
                    $connection->quoteIdentifier($row['attname']),
9591
                    $connection->quoteIdentifier($row['schemaname'] . '.' . $row['tablename'])
9592
                )
9593
            );
9594
        }
9595
    }
9596
9597
    /**
9598
     * Return the cache entry identifier for field evals
9599
     *
9600
     * @param string $additionalIdentifier
9601
     * @return string
9602
     */
9603
    protected function getFieldEvalCacheIdentifier($additionalIdentifier)
9604
    {
9605
        return 'core-datahandler-eval-' . md5($additionalIdentifier);
9606
    }
9607
9608
    /**
9609
     * @return RelationHandler
9610
     */
9611
    protected function createRelationHandlerInstance()
9612
    {
9613
        $isWorkspacesLoaded = ExtensionManagementUtility::isLoaded('workspaces');
9614
        $relationHandler = GeneralUtility::makeInstance(RelationHandler::class);
9615
        $relationHandler->setWorkspaceId($this->BE_USER->workspace);
9616
        $relationHandler->setUseLiveReferenceIds($isWorkspacesLoaded);
9617
        $relationHandler->setUseLiveParentIds($isWorkspacesLoaded);
9618
        $relationHandler->setReferenceIndexUpdater($this->referenceIndexUpdater);
9619
        return $relationHandler;
9620
    }
9621
9622
    /**
9623
     * Create and returns an instance of the CacheManager
9624
     *
9625
     * @return CacheManager
9626
     */
9627
    protected function getCacheManager()
9628
    {
9629
        return GeneralUtility::makeInstance(CacheManager::class);
9630
    }
9631
9632
    /**
9633
     * Gets the resourceFactory
9634
     *
9635
     * @return ResourceFactory
9636
     */
9637
    protected function getResourceFactory()
9638
    {
9639
        return GeneralUtility::makeInstance(ResourceFactory::class);
9640
    }
9641
9642
    /**
9643
     * @return LanguageService
9644
     */
9645
    protected function getLanguageService()
9646
    {
9647
        return $GLOBALS['LANG'];
9648
    }
9649
9650
    /**
9651
     * @internal should only be used from within TYPO3 Core
9652
     * @return array
9653
     */
9654
    public function getHistoryRecords(): array
9655
    {
9656
        return $this->historyRecords;
9657
    }
9658
}
9659