DataHandler::addDefaultPermittedLanguageIfNotSet()   A
last analyzed

Complexity

Conditions 5
Paths 7

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 12
c 0
b 0
f 0
dl 0
loc 19
rs 9.5555
cc 5
nc 7
nop 3
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\FlexForm\Exception\InvalidIdentifierException;
29
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowException;
30
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowLoopException;
31
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidParentRowRootException;
32
use TYPO3\CMS\Core\Configuration\FlexForm\Exception\InvalidPointerFieldValueException;
33
use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
34
use TYPO3\CMS\Core\Configuration\Richtext;
35
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
36
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
37
use TYPO3\CMS\Core\Database\Connection;
38
use TYPO3\CMS\Core\Database\ConnectionPool;
39
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
40
use TYPO3\CMS\Core\Database\Query\QueryHelper;
41
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
42
use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface;
43
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
44
use TYPO3\CMS\Core\Database\RelationHandler;
45
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
46
use TYPO3\CMS\Core\DataHandling\Localization\DataMapProcessor;
47
use TYPO3\CMS\Core\DataHandling\Model\CorrelationId;
48
use TYPO3\CMS\Core\DataHandling\Model\RecordStateFactory;
49
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
50
use TYPO3\CMS\Core\Html\RteHtmlParser;
51
use TYPO3\CMS\Core\Localization\LanguageService;
52
use TYPO3\CMS\Core\Messaging\FlashMessage;
53
use TYPO3\CMS\Core\Messaging\FlashMessageService;
54
use TYPO3\CMS\Core\Resource\ResourceFactory;
55
use TYPO3\CMS\Core\Service\OpcodeCacheService;
56
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
57
use TYPO3\CMS\Core\Site\SiteFinder;
58
use TYPO3\CMS\Core\SysLog\Action as SystemLogGenericAction;
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
    /**
541
     * Tells, that this DataHandler instance was called from \TYPO3\CMS\Impext\ImportExport.
542
     * This variable is set by \TYPO3\CMS\Impext\ImportExport
543
     *
544
     * @var bool
545
     * @internal only used within TYPO3 Core
546
     */
547
    public $callFromImpExp = false;
548
549
    // Various
550
551
    /**
552
     * Set to "currentRecord" during checking of values.
553
     *
554
     * @var array
555
     * @internal
556
     */
557
    public $checkValue_currentRecord = [];
558
559
    /**
560
     * Disable delete clause
561
     *
562
     * @var bool
563
     */
564
    protected $disableDeleteClause = false;
565
566
    /**
567
     * @var array
568
     */
569
    protected $checkModifyAccessListHookObjects;
570
571
    /**
572
     * @var array
573
     */
574
    protected $version_remapMMForVersionSwap_reg;
575
576
    /**
577
     * The outer most instance of \TYPO3\CMS\Core\DataHandling\DataHandler:
578
     * This object instantiates itself on versioning and localization ...
579
     *
580
     * @var \TYPO3\CMS\Core\DataHandling\DataHandler
581
     */
582
    protected $outerMostInstance;
583
584
    /**
585
     * Internal cache for collecting records that should trigger cache clearing
586
     *
587
     * @var array
588
     */
589
    protected static $recordsToClearCacheFor = [];
590
591
    /**
592
     * Internal cache for pids of records which were deleted. It's not possible
593
     * to retrieve the parent folder/page at a later stage
594
     *
595
     * @var array
596
     */
597
    protected static $recordPidsForDeletedRecords = [];
598
599
    /**
600
     * Runtime Cache to store and retrieve data computed for a single request
601
     *
602
     * @var \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
603
     */
604
    protected $runtimeCache;
605
606
    /**
607
     * Prefix for the cache entries of nested element calls since the runtimeCache has a global scope.
608
     *
609
     * @var string
610
     */
611
    protected $cachePrefixNestedElementCalls = 'core-datahandler-nestedElementCalls-';
612
613
    /**
614
     * Sets up the data handler cache and some additional options, the main logic is done in the start() method.
615
     *
616
     * @param ReferenceIndexUpdater|null $referenceIndexUpdater Hand over from outer most instance to sub instances
617
     */
618
    public function __construct(ReferenceIndexUpdater $referenceIndexUpdater = null)
619
    {
620
        $this->checkStoredRecords = (bool)$GLOBALS['TYPO3_CONF_VARS']['BE']['checkStoredRecords'];
621
        $this->checkStoredRecords_loose = (bool)$GLOBALS['TYPO3_CONF_VARS']['BE']['checkStoredRecordsLoose'];
622
        $this->runtimeCache = $this->getRuntimeCache();
623
        $this->pagePermissionAssembler = GeneralUtility::makeInstance(PagePermissionAssembler::class, $GLOBALS['TYPO3_CONF_VARS']['BE']['defaultPermissions']);
624
        if ($referenceIndexUpdater === null) {
625
            // Create ReferenceIndexUpdater object. This should only happen on outer most instance,
626
            // sub instances should receive the reference index updater from a parent.
627
            $referenceIndexUpdater = GeneralUtility::makeInstance(ReferenceIndexUpdater::class);
628
        }
629
        $this->referenceIndexUpdater = $referenceIndexUpdater;
630
    }
631
632
    /**
633
     * @param array $control
634
     * @internal
635
     */
636
    public function setControl(array $control)
637
    {
638
        $this->control = $control;
639
    }
640
641
    /**
642
     * Initializing.
643
     * For details, see 'TYPO3 Core API' document.
644
     * This function does not start the processing of data, but merely initializes the object
645
     *
646
     * @param array $data Data to be modified or inserted in the database
647
     * @param array $cmd Commands to copy, move, delete, localize, versionize records.
648
     * @param BackendUserAuthentication|null $altUserObject An alternative userobject you can set instead of the default, which is $GLOBALS['BE_USER']
649
     */
650
    public function start($data, $cmd, $altUserObject = null)
651
    {
652
        // Initializing BE_USER
653
        $this->BE_USER = is_object($altUserObject) ? $altUserObject : $GLOBALS['BE_USER'];
654
        $this->userid = $this->BE_USER->user['uid'] ?? 0;
655
        $this->username = $this->BE_USER->user['username'] ?? '';
656
        $this->admin = $this->BE_USER->user['admin'] ?? false;
657
658
        // set correlation id for each new set of data or commands
659
        $this->correlationId = CorrelationId::forScope(
660
            md5(StringUtility::getUniqueId(self::class))
661
        );
662
663
        // Get default values from user TSconfig
664
        $tcaDefaultOverride = $this->BE_USER->getTSConfig()['TCAdefaults.'] ?? null;
665
        if (is_array($tcaDefaultOverride)) {
666
            $this->setDefaultsFromUserTS($tcaDefaultOverride);
667
        }
668
669
        // generates the excludelist, based on TCA/exclude-flag and non_exclude_fields for the user:
670
        if (!$this->admin) {
671
            $this->excludedTablesAndFields = array_flip($this->getExcludeListArray());
672
        }
673
        // Setting the data and cmd arrays
674
        if (is_array($data)) {
0 ignored issues
show
introduced by
The condition is_array($data) is always true.
Loading history...
675
            reset($data);
676
            $this->datamap = $data;
677
        }
678
        if (is_array($cmd)) {
0 ignored issues
show
introduced by
The condition is_array($cmd) is always true.
Loading history...
679
            reset($cmd);
680
            $this->cmdmap = $cmd;
681
        }
682
    }
683
684
    /**
685
     * Function that can mirror input values in datamap-array to other uid numbers.
686
     * 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]
687
     *
688
     * @param array $mirror This array has the syntax $mirror[table_name][uid] = [list of uids to copy data-value TO!]
689
     * @internal
690
     */
691
    public function setMirror($mirror)
692
    {
693
        if (!is_array($mirror)) {
0 ignored issues
show
introduced by
The condition is_array($mirror) is always true.
Loading history...
694
            return;
695
        }
696
697
        foreach ($mirror as $table => $uid_array) {
698
            if (!isset($this->datamap[$table])) {
699
                continue;
700
            }
701
702
            foreach ($uid_array as $id => $uidList) {
703
                if (!isset($this->datamap[$table][$id])) {
704
                    continue;
705
                }
706
707
                $theIdsInArray = GeneralUtility::trimExplode(',', $uidList, true);
708
                foreach ($theIdsInArray as $copyToUid) {
709
                    $this->datamap[$table][$copyToUid] = $this->datamap[$table][$id];
710
                }
711
            }
712
        }
713
    }
714
715
    /**
716
     * Initializes default values coming from User TSconfig
717
     *
718
     * @param array $userTS User TSconfig array
719
     * @internal should only be used from within DataHandler
720
     */
721
    public function setDefaultsFromUserTS($userTS)
722
    {
723
        if (!is_array($userTS)) {
0 ignored issues
show
introduced by
The condition is_array($userTS) is always true.
Loading history...
724
            return;
725
        }
726
727
        foreach ($userTS as $k => $v) {
728
            $k = mb_substr($k, 0, -1);
729
            if (!$k || !is_array($v) || !isset($GLOBALS['TCA'][$k])) {
730
                continue;
731
            }
732
733
            if (is_array($this->defaultValues[$k] ?? false)) {
734
                $this->defaultValues[$k] = array_merge($this->defaultValues[$k], $v);
735
            } else {
736
                $this->defaultValues[$k] = $v;
737
            }
738
        }
739
    }
740
741
    /**
742
     * When a new record is created, all values that haven't been set but are set via PageTSconfig / UserTSconfig
743
     * get applied here.
744
     *
745
     * This is only executed for new records. The most important part is that the pageTS of the actual resolved $pid
746
     * is taken, and a new field array with empty defaults is set again.
747
     *
748
     * @param string $table
749
     * @param int $pageId
750
     * @param array $prepopulatedFieldArray
751
     * @return array
752
     */
753
    protected function applyDefaultsForFieldArray(string $table, int $pageId, array $prepopulatedFieldArray): array
754
    {
755
        // First set TCAdefaults respecting the given PageID
756
        $tcaDefaults = BackendUtility::getPagesTSconfig($pageId)['TCAdefaults.'] ?? null;
757
        // Re-apply $this->defaultValues settings
758
        $this->setDefaultsFromUserTS($tcaDefaults);
759
        $cleanFieldArray = $this->newFieldArray($table);
760
        if (isset($prepopulatedFieldArray['pid'])) {
761
            $cleanFieldArray['pid'] = $prepopulatedFieldArray['pid'];
762
        }
763
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? null;
764
        if ($sortColumn !== null && isset($prepopulatedFieldArray[$sortColumn])) {
765
            $cleanFieldArray[$sortColumn] = $prepopulatedFieldArray[$sortColumn];
766
        }
767
        return $cleanFieldArray;
768
    }
769
770
    /*********************************************
771
     *
772
     * HOOKS
773
     *
774
     *********************************************/
775
    /**
776
     * Hook: processDatamap_afterDatabaseOperations
777
     * (calls $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);)
778
     *
779
     * Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
780
     * but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
781
     *
782
     * @param array $hookObjectsArr (reference) Array with hook objects
783
     * @param string $status (reference) Status of the current operation, 'new' or 'update
784
     * @param string $table (reference) The table currently processing data for
785
     * @param string $id (reference) The record uid currently processing data for, [integer] or [string] (like 'NEW...')
786
     * @param array $fieldArray (reference) The field array of a record
787
     * @internal should only be used from within DataHandler
788
     */
789
    public function hook_processDatamap_afterDatabaseOperations(&$hookObjectsArr, &$status, &$table, &$id, &$fieldArray)
790
    {
791
        // Process hook directly:
792
        if (!isset($this->remapStackRecords[$table][$id])) {
793
            foreach ($hookObjectsArr as $hookObj) {
794
                if (method_exists($hookObj, 'processDatamap_afterDatabaseOperations')) {
795
                    $hookObj->processDatamap_afterDatabaseOperations($status, $table, $id, $fieldArray, $this);
796
                }
797
            }
798
        } else {
799
            $this->remapStackRecords[$table][$id]['processDatamap_afterDatabaseOperations'] = [
800
                'status' => $status,
801
                'fieldArray' => $fieldArray,
802
                'hookObjectsArr' => $hookObjectsArr
803
            ];
804
        }
805
    }
806
807
    /**
808
     * Gets the 'checkModifyAccessList' hook objects.
809
     * The first call initializes the accordant objects.
810
     *
811
     * @return array The 'checkModifyAccessList' hook objects (if any)
812
     * @throws \UnexpectedValueException
813
     */
814
    protected function getCheckModifyAccessListHookObjects()
815
    {
816
        if (!isset($this->checkModifyAccessListHookObjects)) {
817
            $this->checkModifyAccessListHookObjects = [];
818
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['checkModifyAccessList'] ?? [] as $className) {
819
                $hookObject = GeneralUtility::makeInstance($className);
820
                if (!$hookObject instanceof DataHandlerCheckModifyAccessListHookInterface) {
821
                    throw new \UnexpectedValueException($className . ' must implement interface ' . DataHandlerCheckModifyAccessListHookInterface::class, 1251892472);
822
                }
823
                $this->checkModifyAccessListHookObjects[] = $hookObject;
824
            }
825
        }
826
        return $this->checkModifyAccessListHookObjects;
827
    }
828
829
    /*********************************************
830
     *
831
     * PROCESSING DATA
832
     *
833
     *********************************************/
834
    /**
835
     * Processing the data-array
836
     * Call this function to process the data-array set by start()
837
     *
838
     * @return bool|void
839
     */
840
    public function process_datamap()
841
    {
842
        $this->controlActiveElements();
843
844
        // Keep versionized(!) relations here locally:
845
        $registerDBList = [];
846
        $this->registerElementsToBeDeleted();
847
        $this->datamap = $this->unsetElementsToBeDeleted($this->datamap);
848
        // Editing frozen:
849
        if ($this->BE_USER->workspace !== 0 && ($this->BE_USER->workspaceRec['freeze'] ?? false)) {
850
            $this->newlog('All editing in this workspace has been frozen!', SystemLogErrorClassification::USER_ERROR);
851
            return false;
852
        }
853
        // First prepare user defined objects (if any) for hooks which extend this function:
854
        $hookObjectsArr = [];
855
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processDatamapClass'] ?? [] as $className) {
856
            $hookObject = GeneralUtility::makeInstance($className);
857
            if (method_exists($hookObject, 'processDatamap_beforeStart')) {
858
                $hookObject->processDatamap_beforeStart($this);
859
            }
860
            $hookObjectsArr[] = $hookObject;
861
        }
862
        // Pre-process data-map and synchronize localization states
863
        $this->datamap = GeneralUtility::makeInstance(SlugEnricher::class)->enrichDataMap($this->datamap);
864
        $this->datamap = DataMapProcessor::instance($this->datamap, $this->BE_USER, $this->referenceIndexUpdater)->process();
865
        // 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.
866
        $orderOfTables = [];
867
        // Set pages first.
868
        if (isset($this->datamap['pages'])) {
869
            $orderOfTables[] = 'pages';
870
        }
871
        $orderOfTables = array_unique(array_merge($orderOfTables, array_keys($this->datamap)));
872
        // Process the tables...
873
        foreach ($orderOfTables as $table) {
874
            // Check if
875
            //	   - table is set in $GLOBALS['TCA'],
876
            //	   - table is NOT readOnly
877
            //	   - the table is set with content in the data-array (if not, there's nothing to process...)
878
            //	   - permissions for tableaccess OK
879
            $modifyAccessList = $this->checkModifyAccessList($table);
880
            if (!$modifyAccessList) {
881
                $this->log($table, 0, SystemLogDatabaseAction::UPDATE, 0, SystemLogErrorClassification::USER_ERROR, 'Attempt to modify table \'%s\' without permission', 1, [$table]);
882
            }
883
            if (!isset($GLOBALS['TCA'][$table]) || $this->tableReadOnly($table) || !is_array($this->datamap[$table]) || !$modifyAccessList) {
884
                continue;
885
            }
886
887
            if ($this->reverseOrder) {
888
                $this->datamap[$table] = array_reverse($this->datamap[$table], true);
889
            }
890
            // For each record from the table, do:
891
            // $id is the record uid, may be a string if new records...
892
            // $incomingFieldArray is the array of fields
893
            foreach ($this->datamap[$table] as $id => $incomingFieldArray) {
894
                if (!is_array($incomingFieldArray)) {
895
                    continue;
896
                }
897
                $theRealPid = null;
898
899
                // Hook: processDatamap_preProcessFieldArray
900
                foreach ($hookObjectsArr as $hookObj) {
901
                    if (method_exists($hookObj, 'processDatamap_preProcessFieldArray')) {
902
                        $hookObj->processDatamap_preProcessFieldArray($incomingFieldArray, $table, $id, $this);
903
                    }
904
                }
905
                // ******************************
906
                // Checking access to the record
907
                // ******************************
908
                $createNewVersion = false;
909
                $old_pid_value = '';
910
                // Is it a new record? (Then Id is a string)
911
                if (!MathUtility::canBeInterpretedAsInteger($id)) {
912
                    // Get a fieldArray with tca default values
913
                    $fieldArray = $this->newFieldArray($table);
914
                    // A pid must be set for new records.
915
                    if (isset($incomingFieldArray['pid'])) {
916
                        $pid_value = $incomingFieldArray['pid'];
917
                        // Checking and finding numerical pid, it may be a string-reference to another value
918
                        $canProceed = true;
919
                        // If a NEW... id
920
                        if (strpos($pid_value, 'NEW') !== false) {
921
                            if ($pid_value[0] === '-') {
922
                                $negFlag = -1;
923
                                $pid_value = substr($pid_value, 1);
924
                            } else {
925
                                $negFlag = 1;
926
                            }
927
                            // Trying to find the correct numerical value as it should be mapped by earlier processing of another new record.
928
                            if (isset($this->substNEWwithIDs[$pid_value])) {
929
                                if ($negFlag === 1) {
930
                                    $old_pid_value = $this->substNEWwithIDs[$pid_value];
931
                                }
932
                                $pid_value = (int)($negFlag * $this->substNEWwithIDs[$pid_value]);
933
                            } else {
934
                                $canProceed = false;
935
                            }
936
                        }
937
                        $pid_value = (int)$pid_value;
938
                        if ($canProceed) {
939
                            $fieldArray = $this->resolveSortingAndPidForNewRecord($table, $pid_value, $fieldArray);
940
                        }
941
                    }
942
                    $theRealPid = $fieldArray['pid'];
943
                    // Checks if records can be inserted on this $pid.
944
                    // If this is a page translation, the check needs to be done for the l10n_parent record
945
                    $languageField = $GLOBALS['TCA'][$table]['ctrl']['languageField'] ?? null;
946
                    $transOrigPointerField = $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] ?? null;
947
                    if ($table === 'pages'
948
                        && $languageField && isset($incomingFieldArray[$languageField]) && $incomingFieldArray[$languageField] > 0
949
                        && $transOrigPointerField && isset($incomingFieldArray[$transOrigPointerField]) && $incomingFieldArray[$transOrigPointerField] > 0
950
                    ) {
951
                        $recordAccess = $this->checkRecordInsertAccess($table, $incomingFieldArray[$transOrigPointerField]);
952
                    } else {
953
                        $recordAccess = $this->checkRecordInsertAccess($table, $theRealPid);
954
                    }
955
                    if ($recordAccess) {
956
                        $this->addDefaultPermittedLanguageIfNotSet($table, $incomingFieldArray, $theRealPid);
957
                        $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $incomingFieldArray, true);
958
                        if (!$recordAccess) {
959
                            $this->newlog('recordEditAccessInternals() check failed. [' . $this->BE_USER->errorMsg . ']', SystemLogErrorClassification::USER_ERROR);
960
                        } elseif (!$this->bypassWorkspaceRestrictions && !$this->BE_USER->workspaceAllowsLiveEditingInTable($table)) {
961
                            // If LIVE records cannot be created due to workspace restrictions, prepare creation of placeholder-record
962
                            // So, if no live records were allowed in the current workspace, we have to create a new version of this record
963
                            if (BackendUtility::isTableWorkspaceEnabled($table)) {
964
                                $createNewVersion = true;
965
                            } else {
966
                                $recordAccess = false;
967
                                $this->newlog('Record could not be created in this workspace', SystemLogErrorClassification::USER_ERROR);
968
                            }
969
                        }
970
                    }
971
                    // Yes new record, change $record_status to 'insert'
972
                    $status = 'new';
973
                } else {
974
                    // Nope... $id is a number
975
                    $id = (int)$id;
976
                    $fieldArray = [];
977
                    $recordAccess = $this->checkRecordUpdateAccess($table, $id, $incomingFieldArray, $hookObjectsArr);
978
                    if (!$recordAccess) {
979
                        if ($this->enableLogging) {
980
                            $propArr = $this->getRecordProperties($table, $id);
981
                            $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']);
982
                        }
983
                        continue;
984
                    }
985
                    // Next check of the record permissions (internals)
986
                    $recordAccess = $this->BE_USER->recordEditAccessInternals($table, $id);
987
                    if (!$recordAccess) {
988
                        $this->newlog('recordEditAccessInternals() check failed. [' . $this->BE_USER->errorMsg . ']', SystemLogErrorClassification::USER_ERROR);
989
                    } else {
990
                        // Here we fetch the PID of the record that we point to...
991
                        $tempdata = $this->recordInfo($table, $id, 'pid' . (BackendUtility::isTableWorkspaceEnabled($table) ? ',t3ver_oid,t3ver_wsid,t3ver_stage' : ''));
992
                        $theRealPid = $tempdata['pid'] ?? null;
993
                        // Use the new id of the versionized record we're trying to write to:
994
                        // (This record is a child record of a parent and has already been versionized.)
995
                        if (!empty($this->autoVersionIdMap[$table][$id])) {
996
                            // For the reason that creating a new version of this record, automatically
997
                            // created related child records (e.g. "IRRE"), update the accordant field:
998
                            $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
999
                            // Use the new id of the copied/versionized record:
1000
                            $id = $this->autoVersionIdMap[$table][$id];
1001
                            $recordAccess = true;
1002
                        } elseif (!$this->bypassWorkspaceRestrictions && ($errorCode = $this->BE_USER->workspaceCannotEditRecord($table, $tempdata))) {
1003
                            $recordAccess = false;
1004
                            // Versioning is required and it must be offline version!
1005
                            // Check if there already is a workspace version
1006
                            $workspaceVersion = BackendUtility::getWorkspaceVersionOfRecord($this->BE_USER->workspace, $table, $id, 'uid,t3ver_oid');
1007
                            if ($workspaceVersion) {
1008
                                $id = $workspaceVersion['uid'];
1009
                                $recordAccess = true;
1010
                            } elseif ($this->BE_USER->workspaceAllowAutoCreation($table, $id, $theRealPid)) {
1011
                                // new version of a record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1012
                                $this->pagetreeNeedsRefresh = true;
1013
1014
                                /** @var DataHandler $tce */
1015
                                $tce = GeneralUtility::makeInstance(__CLASS__, $this->referenceIndexUpdater);
1016
                                $tce->enableLogging = $this->enableLogging;
1017
                                // Setting up command for creating a new version of the record:
1018
                                $cmd = [];
1019
                                $cmd[$table][$id]['version'] = [
1020
                                    'action' => 'new',
1021
                                    // Default is to create a version of the individual records
1022
                                    'label' => 'Auto-created for WS #' . $this->BE_USER->workspace
1023
                                ];
1024
                                $tce->start([], $cmd, $this->BE_USER);
1025
                                $tce->process_cmdmap();
1026
                                $this->errorLog = array_merge($this->errorLog, $tce->errorLog);
1027
                                // If copying was successful, share the new uids (also of related children):
1028
                                if (!empty($tce->copyMappingArray[$table][$id])) {
1029
                                    foreach ($tce->copyMappingArray as $origTable => $origIdArray) {
1030
                                        foreach ($origIdArray as $origId => $newId) {
1031
                                            $this->autoVersionIdMap[$origTable][$origId] = $newId;
1032
                                        }
1033
                                    }
1034
                                    // Update registerDBList, that holds the copied relations to child records:
1035
                                    $registerDBList = array_merge($registerDBList, $tce->registerDBList);
1036
                                    // For the reason that creating a new version of this record, automatically
1037
                                    // created related child records (e.g. "IRRE"), update the accordant field:
1038
                                    $this->getVersionizedIncomingFieldArray($table, $id, $incomingFieldArray, $registerDBList);
1039
                                    // Use the new id of the copied/versionized record:
1040
                                    $id = $this->autoVersionIdMap[$table][$id];
1041
                                    $recordAccess = true;
1042
                                } else {
1043
                                    $this->newlog('Could not be edited in offline workspace in the branch where found (failure state: \'' . $errorCode . '\'). Auto-creation of version failed!', SystemLogErrorClassification::USER_ERROR);
1044
                                }
1045
                            } else {
1046
                                $this->newlog('Could not be edited in offline workspace in the branch where found (failure state: \'' . $errorCode . '\'). Auto-creation of version not allowed in workspace!', SystemLogErrorClassification::USER_ERROR);
1047
                            }
1048
                        }
1049
                    }
1050
                    // The default is 'update'
1051
                    $status = 'update';
1052
                }
1053
                // If access was granted above, proceed to create or update record:
1054
                if (!$recordAccess) {
1055
                    continue;
1056
                }
1057
1058
                // Here the "pid" is set IF NOT the old pid was a string pointing to a place in the subst-id array.
1059
                [$tscPID] = BackendUtility::getTSCpid($table, $id, $old_pid_value ?: ($fieldArray['pid'] ?? 0));
1060
                if ($status === 'new') {
1061
                    // Apply TCAdefaults from pageTS
1062
                    $fieldArray = $this->applyDefaultsForFieldArray($table, (int)$tscPID, $fieldArray);
1063
                    // Apply page permissions as well
1064
                    if ($table === 'pages') {
1065
                        $fieldArray = $this->pagePermissionAssembler->applyDefaults(
1066
                            $fieldArray,
1067
                            (int)$tscPID,
1068
                            (int)$this->userid,
1069
                            (int)$this->BE_USER->firstMainGroup
1070
                        );
1071
                    }
1072
                    // Ensure that the default values, that are stored in the $fieldArray (built from internal default values)
1073
                    // Are also placed inside the incomingFieldArray, so this is checked in "fillInFieldArray" and
1074
                    // all default values are also checked for validity
1075
                    // This allows to set TCAdefaults (for example) without having to use FormEngine to have the fields available first.
1076
                    $incomingFieldArray = array_replace_recursive($fieldArray, $incomingFieldArray);
1077
                }
1078
                // Processing of all fields in incomingFieldArray and setting them in $fieldArray
1079
                $fieldArray = $this->fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
1080
                // NOTICE! All manipulation beyond this point bypasses both "excludeFields" AND possible "MM" relations to field!
1081
                // Forcing some values unto field array:
1082
                // NOTICE: This overriding is potentially dangerous; permissions per field is not checked!!!
1083
                $fieldArray = $this->overrideFieldArray($table, $fieldArray);
1084
                // Setting system fields
1085
                if ($status === 'new') {
1086
                    if ($GLOBALS['TCA'][$table]['ctrl']['crdate'] ?? false) {
1087
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['crdate']] = $GLOBALS['EXEC_TIME'];
1088
                    }
1089
                    if ($GLOBALS['TCA'][$table]['ctrl']['cruser_id'] ?? false) {
1090
                        $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['cruser_id']] = $this->userid;
1091
                    }
1092
                } elseif ($this->checkSimilar) {
1093
                    // Removing fields which are equal to the current value:
1094
                    $fieldArray = $this->compareFieldArrayWithCurrentAndUnset($table, $id, $fieldArray);
1095
                }
1096
                if (($GLOBALS['TCA'][$table]['ctrl']['tstamp'] ?? false) && !empty($fieldArray)) {
1097
                    $fieldArray[$GLOBALS['TCA'][$table]['ctrl']['tstamp']] = $GLOBALS['EXEC_TIME'];
1098
                }
1099
                // Set stage to "Editing" to make sure we restart the workflow
1100
                if (BackendUtility::isTableWorkspaceEnabled($table)) {
1101
                    $fieldArray['t3ver_stage'] = 0;
1102
                }
1103
                // Hook: processDatamap_postProcessFieldArray
1104
                foreach ($hookObjectsArr as $hookObj) {
1105
                    if (method_exists($hookObj, 'processDatamap_postProcessFieldArray')) {
1106
                        $hookObj->processDatamap_postProcessFieldArray($status, $table, $id, $fieldArray, $this);
1107
                    }
1108
                }
1109
                // Performing insert/update. If fieldArray has been unset by some userfunction (see hook above), don't do anything
1110
                // Kasper: Unsetting the fieldArray is dangerous; MM relations might be saved already
1111
                if (is_array($fieldArray)) {
1112
                    if ($status === 'new') {
1113
                        if ($table === 'pages') {
1114
                            // for new pages always a refresh is needed
1115
                            $this->pagetreeNeedsRefresh = true;
1116
                        }
1117
1118
                        // This creates a version of the record, instead of adding it to the live workspace
1119
                        if ($createNewVersion) {
1120
                            // new record created in a workspace - so always refresh pagetree to indicate there is a change in the workspace
1121
                            $this->pagetreeNeedsRefresh = true;
1122
                            $fieldArray['pid'] = $theRealPid;
1123
                            $fieldArray['t3ver_oid'] = 0;
1124
                            // Setting state for version (so it can know it is currently a new version...)
1125
                            $fieldArray['t3ver_state'] = (string)new VersionState(VersionState::NEW_PLACEHOLDER);
1126
                            $fieldArray['t3ver_wsid'] = $this->BE_USER->workspace;
1127
                            $this->insertDB($table, $id, $fieldArray, true, (int)($incomingFieldArray['uid'] ?? 0));
1128
                            // Hold auto-versionized ids of placeholders
1129
                            $this->autoVersionIdMap[$table][$this->substNEWwithIDs[$id]] = $this->substNEWwithIDs[$id];
1130
                        } else {
1131
                            $this->insertDB($table, $id, $fieldArray, false, (int)($incomingFieldArray['uid'] ?? 0));
1132
                        }
1133
                    } else {
1134
                        if ($table === 'pages') {
1135
                            // only a certain number of fields needs to be checked for updates
1136
                            // if $this->checkSimilar is TRUE, fields with unchanged values are already removed here
1137
                            $fieldsToCheck = array_intersect($this->pagetreeRefreshFieldsFromPages, array_keys($fieldArray));
1138
                            if (!empty($fieldsToCheck)) {
1139
                                $this->pagetreeNeedsRefresh = true;
1140
                            }
1141
                        }
1142
                        $this->updateDB($table, $id, $fieldArray);
1143
                    }
1144
                }
1145
                // Hook: processDatamap_afterDatabaseOperations
1146
                // Note: When using the hook after INSERT operations, you will only get the temporary NEW... id passed to your hook as $id,
1147
                // but you can easily translate it to the real uid of the inserted record using the $this->substNEWwithIDs array.
1148
                $this->hook_processDatamap_afterDatabaseOperations($hookObjectsArr, $status, $table, $id, $fieldArray);
1149
            }
1150
        }
1151
        // Process the stack of relations to remap/correct
1152
        $this->processRemapStack();
1153
        $this->dbAnalysisStoreExec();
1154
        // Hook: processDatamap_afterAllOperations
1155
        // Note: When this hook gets called, all operations on the submitted data have been finished.
1156
        foreach ($hookObjectsArr as $hookObj) {
1157
            if (method_exists($hookObj, 'processDatamap_afterAllOperations')) {
1158
                $hookObj->processDatamap_afterAllOperations($this);
1159
            }
1160
        }
1161
1162
        if ($this->isOuterMostInstance()) {
1163
            $this->referenceIndexUpdater->update();
1164
            $this->processClearCacheQueue();
1165
            $this->resetElementsToBeDeleted();
1166
        }
1167
    }
1168
1169
    /**
1170
     * @param string $table
1171
     * @param string $value
1172
     * @param string $dbType
1173
     * @return string
1174
     */
1175
    protected function normalizeTimeFormat(string $table, string $value, string $dbType): string
1176
    {
1177
        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
1178
        $platform = $connection->getDatabasePlatform();
1179
        if ($platform instanceof SQLServerPlatform) {
1180
            $defaultLength = QueryHelper::getDateTimeFormats()[$dbType]['empty'];
1181
            $value = substr(
1182
                $value,
1183
                0,
1184
                strlen($defaultLength)
1185
            );
1186
        }
1187
        return $value;
1188
    }
1189
1190
    /**
1191
     * Sets the "sorting" DB field and the "pid" field of an incoming record that should be added (NEW1234)
1192
     * depending on the record that should be added or where it should be added.
1193
     *
1194
     * This method is called from process_datamap()
1195
     *
1196
     * @param string $table the table name of the record to insert
1197
     * @param int $pid the real PID (numeric) where the record should be
1198
     * @param array $fieldArray field+value pairs to add
1199
     * @return array the modified field array
1200
     */
1201
    protected function resolveSortingAndPidForNewRecord(string $table, int $pid, array $fieldArray): array
1202
    {
1203
        $sortColumn = $GLOBALS['TCA'][$table]['ctrl']['sortby'] ?? '';
1204
        // Points to a page on which to insert the element, possibly in the top of the page
1205
        if ($pid >= 0) {
1206
            // Ensure that the "pid" is not a translated page ID, but the default page ID
1207
            $pid = $this->getDefaultLanguagePageId($pid);
1208
            // The numerical pid is inserted in the data array
1209
            $fieldArray['pid'] = $pid;
1210
            // If this table is sorted we better find the top sorting number
1211
            if ($sortColumn) {
1212
                $fieldArray[$sortColumn] = $this->getSortNumber($table, 0, $pid);
1213
            }
1214
        } elseif ($sortColumn) {
1215
            // Points to another record before itself
1216
            // If this table is sorted we better find the top sorting number
1217
            // Because $pid is < 0, getSortNumber() returns an array
1218
            $sortingInfo = $this->getSortNumber($table, 0, $pid);
1219
            $fieldArray['pid'] = $sortingInfo['pid'];
1220
            $fieldArray[$sortColumn] = $sortingInfo['sortNumber'];
1221
        } else {
1222
            // Here we fetch the PID of the record that we point to
1223
            $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

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

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

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

2334
                $statement->/** @scrutinizer ignore-call */ 
2335
                            bindValue(1, $newValue);
Loading history...
2335
                $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

2335
                $statement->/** @scrutinizer ignore-call */ 
2336
                            execute();
Loading history...
2336
                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

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

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

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

2718
                $dbAnalysis->writeMM($tcaFieldConf['MM'], $id, /** @scrutinizer ignore-type */ $prep);
Loading history...
2719
                if ($oldRelations != $newRelations) {
2720
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = $oldRelations;
2721
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = $newRelations;
2722
                } else {
2723
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['oldRecord'][$currentField] = '';
2724
                    $this->mmHistoryRecords[$currentTable . ':' . $id]['newRecord'][$currentField] = '';
2725
                }
2726
            } else {
2727
                $this->dbAnalysisStore[] = [$dbAnalysis, $tcaFieldConf['MM'], $id, $prep, $currentTable];
2728
            }
2729
            $valueArray = $dbAnalysis->countItems();
2730
        } else {
2731
            $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

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

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

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

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

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

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

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

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

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

5784
                    if (!empty($dbAnalysis->getValueArray(/** @scrutinizer ignore-type */ $prependName))) {
Loading history...
5785
                        $this->version_remapMMForVersionSwap_reg[$id][$field] = [$dbAnalysis, $conf['MM'], $prependName];
5786
                    }
5787
                    $dbAnalysis = $this->createRelationHandlerInstance();
5788
                    $dbAnalysis->start('', $allowedTables, $conf['MM'], $swapWith, $table, $conf);
5789
                    if (!empty($dbAnalysis->getValueArray($prependName))) {
5790
                        $this->version_remapMMForVersionSwap_reg[$swapWith][$field] = [$dbAnalysis, $conf['MM'], $prependName];
5791
                    }
5792
                }
5793
            } elseif ($conf['type'] === 'flex') {
5794
                // Current record
5795
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5796
                    $fConf,
5797
                    $table,
5798
                    $field,
5799
                    $currentRec
5800
                );
5801
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5802
                $currentValueArray = GeneralUtility::xml2array($currentRec[$field]);
5803
                if (is_array($currentValueArray)) {
5804
                    $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $id, $field], 'version_remapMMForVersionSwap_flexFormCallBack');
5805
                }
5806
                // Swap record
5807
                $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5808
                    $fConf,
5809
                    $table,
5810
                    $field,
5811
                    $swapRec
5812
                );
5813
                $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5814
                $currentValueArray = GeneralUtility::xml2array($swapRec[$field]);
5815
                if (is_array($currentValueArray)) {
5816
                    $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $swapWith, $field], 'version_remapMMForVersionSwap_flexFormCallBack');
5817
                }
5818
            }
5819
        }
5820
        // Execute:
5821
        $this->version_remapMMForVersionSwap_execSwap($table, $id, $swapWith);
5822
    }
5823
5824
    /**
5825
     * Callback function for traversing the FlexForm structure in relation to ...
5826
     *
5827
     * @param array $pParams Array of parameters in num-indexes: table, uid, field
5828
     * @param array $dsConf TCA field configuration (from Data Structure XML)
5829
     * @param string $dataValue The value of the flexForm field
5830
     * @param string $dataValue_ext1 Not used.
5831
     * @param string $dataValue_ext2 Not used.
5832
     * @param string $path Path in flexforms
5833
     * @see version_remapMMForVersionSwap()
5834
     * @see checkValue_flex_procInData_travDS()
5835
     * @internal should only be used from within DataHandler
5836
     */
5837
    public function version_remapMMForVersionSwap_flexFormCallBack($pParams, $dsConf, $dataValue, $dataValue_ext1, $dataValue_ext2, $path)
5838
    {
5839
        // Extract parameters:
5840
        [$table, $uid, $field] = $pParams;
5841
        if ($this->isReferenceField($dsConf)) {
5842
            $allowedTables = $dsConf['type'] === 'group' ? $dsConf['allowed'] : $dsConf['foreign_table'];
5843
            $prependName = $dsConf['type'] === 'group' ? $dsConf['prepend_tname'] : '';
5844
            if ($dsConf['MM']) {
5845
                /** @var RelationHandler $dbAnalysis */
5846
                $dbAnalysis = $this->createRelationHandlerInstance();
5847
                $dbAnalysis->start('', $allowedTables, $dsConf['MM'], $uid, $table, $dsConf);
5848
                $this->version_remapMMForVersionSwap_reg[$uid][$field . '/' . $path] = [$dbAnalysis, $dsConf['MM'], $prependName];
5849
            }
5850
        }
5851
    }
5852
5853
    /**
5854
     * Performing the remapping operations found necessary in version_remapMMForVersionSwap()
5855
     * It must be done in three steps with an intermediate "fake" uid. The UID can be something else than -$id (fx. 9999999+$id if you dare... :-)- as long as it is unique.
5856
     *
5857
     * @param string $table Table for the two input records
5858
     * @param int $id Current record (about to go offline)
5859
     * @param int $swapWith Swap record (about to go online)
5860
     * @see version_remapMMForVersionSwap()
5861
     * @internal should only be used from within DataHandler
5862
     */
5863
    public function version_remapMMForVersionSwap_execSwap($table, $id, $swapWith)
5864
    {
5865
        if (is_array($this->version_remapMMForVersionSwap_reg[$id] ?? false)) {
5866
            foreach ($this->version_remapMMForVersionSwap_reg[$id] as $field => $str) {
5867
                $str[0]->remapMM($str[1], $id, -$id, $str[2]);
5868
            }
5869
        }
5870
        if (is_array($this->version_remapMMForVersionSwap_reg[$swapWith] ?? false)) {
5871
            foreach ($this->version_remapMMForVersionSwap_reg[$swapWith] as $field => $str) {
5872
                $str[0]->remapMM($str[1], $swapWith, $id, $str[2]);
5873
            }
5874
        }
5875
        if (is_array($this->version_remapMMForVersionSwap_reg[$id] ?? false)) {
5876
            foreach ($this->version_remapMMForVersionSwap_reg[$id] as $field => $str) {
5877
                $str[0]->remapMM($str[1], -$id, $swapWith, $str[2]);
5878
            }
5879
        }
5880
    }
5881
5882
    /*********************************************
5883
     *
5884
     * Cmd: Helper functions
5885
     *
5886
     ********************************************/
5887
5888
    /**
5889
     * Returns an instance of DataHandler for handling local datamaps/cmdmaps
5890
     *
5891
     * @return DataHandler
5892
     */
5893
    protected function getLocalTCE()
5894
    {
5895
        $copyTCE = GeneralUtility::makeInstance(DataHandler::class, $this->referenceIndexUpdater);
5896
        $copyTCE->copyTree = $this->copyTree;
5897
        $copyTCE->enableLogging = $this->enableLogging;
5898
        // Transformations should NOT be carried out during copy
5899
        $copyTCE->dontProcessTransformations = true;
5900
        // make sure the isImporting flag is transferred, so all hooks know if
5901
        // the current process is an import process
5902
        $copyTCE->isImporting = $this->isImporting;
5903
        $copyTCE->bypassAccessCheckForRecords = $this->bypassAccessCheckForRecords;
5904
        $copyTCE->bypassWorkspaceRestrictions = $this->bypassWorkspaceRestrictions;
5905
        return $copyTCE;
5906
    }
5907
5908
    /**
5909
     * Processes the fields with references as registered during the copy process. This includes all FlexForm fields which had references.
5910
     * @internal should only be used from within DataHandler
5911
     */
5912
    public function remapListedDBRecords()
5913
    {
5914
        if (!empty($this->registerDBList)) {
5915
            $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
5916
            foreach ($this->registerDBList as $table => $records) {
5917
                foreach ($records as $uid => $fields) {
5918
                    $newData = [];
5919
                    $theUidToUpdate = $this->copyMappingArray_merged[$table][$uid] ?? null;
5920
                    $theUidToUpdate_saveTo = BackendUtility::wsMapId($table, $theUidToUpdate);
5921
                    foreach ($fields as $fieldName => $value) {
5922
                        $conf = $GLOBALS['TCA'][$table]['columns'][$fieldName]['config'];
5923
                        switch ($conf['type']) {
5924
                            case 'group':
5925
                            case 'select':
5926
                                $vArray = $this->remapListedDBRecords_procDBRefs($conf, $value, $theUidToUpdate, $table);
5927
                                if (is_array($vArray)) {
5928
                                    $newData[$fieldName] = implode(',', $vArray);
5929
                                }
5930
                                break;
5931
                            case 'flex':
5932
                                if ($value === 'FlexForm_reference') {
5933
                                    // This will fetch the new row for the element
5934
                                    $origRecordRow = $this->recordInfo($table, $theUidToUpdate, '*');
5935
                                    if (is_array($origRecordRow)) {
5936
                                        BackendUtility::workspaceOL($table, $origRecordRow);
5937
                                        // Get current data structure and value array:
5938
                                        $dataStructureIdentifier = $flexFormTools->getDataStructureIdentifier(
5939
                                            ['config' => $conf],
5940
                                            $table,
5941
                                            $fieldName,
5942
                                            $origRecordRow
5943
                                        );
5944
                                        $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifier);
5945
                                        $currentValueArray = GeneralUtility::xml2array($origRecordRow[$fieldName]);
5946
                                        // Do recursive processing of the XML data:
5947
                                        $currentValueArray['data'] = $this->checkValue_flex_procInData($currentValueArray['data'], [], [], $dataStructureArray, [$table, $theUidToUpdate, $fieldName], 'remapListedDBRecords_flexFormCallBack');
5948
                                        // The return value should be compiled back into XML, ready to insert directly in the field (as we call updateDB() directly later):
5949
                                        if (is_array($currentValueArray['data'])) {
5950
                                            $newData[$fieldName] = $this->checkValue_flexArray2Xml($currentValueArray, true);
5951
                                        }
5952
                                    }
5953
                                }
5954
                                break;
5955
                            case 'inline':
5956
                                $this->remapListedDBRecords_procInline($conf, $value, $uid, $table);
5957
                                break;
5958
                            default:
5959
                                $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

5959
                                $this->logger->/** @scrutinizer ignore-call */ 
5960
                                               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...
5960
                        }
5961
                    }
5962
                    // If any fields were changed, those fields are updated!
5963
                    if (!empty($newData)) {
5964
                        $this->updateDB($table, $theUidToUpdate_saveTo, $newData);
5965
                    }
5966
                }
5967
            }
5968
        }
5969
    }
5970
5971
    /**
5972
     * Callback function for traversing the FlexForm structure in relation to creating copied files of file relations inside of flex form structures.
5973
     *
5974
     * @param array $pParams Set of parameters in numeric array: table, uid, field
5975
     * @param array $dsConf TCA config for field (from Data Structure of course)
5976
     * @param string $dataValue Field value (from FlexForm XML)
5977
     * @param string $dataValue_ext1 Not used
5978
     * @param string $dataValue_ext2 Not used
5979
     * @return array Array where the "value" key carries the value.
5980
     * @see checkValue_flex_procInData_travDS()
5981
     * @see remapListedDBRecords()
5982
     * @internal should only be used from within DataHandler
5983
     */
5984
    public function remapListedDBRecords_flexFormCallBack($pParams, $dsConf, $dataValue, $dataValue_ext1, $dataValue_ext2)
5985
    {
5986
        // Extract parameters:
5987
        [$table, $uid, $field] = $pParams;
5988
        // If references are set for this field, set flag so they can be corrected later:
5989
        if ($this->isReferenceField($dsConf) && (string)$dataValue !== '') {
5990
            $vArray = $this->remapListedDBRecords_procDBRefs($dsConf, $dataValue, $uid, $table);
5991
            if (is_array($vArray)) {
5992
                $dataValue = implode(',', $vArray);
5993
            }
5994
        }
5995
        // Return
5996
        return ['value' => $dataValue];
5997
    }
5998
5999
    /**
6000
     * Performs remapping of old UID values to NEW uid values for a DB reference field.
6001
     *
6002
     * @param array $conf TCA field config
6003
     * @param string $value Field value
6004
     * @param int $MM_localUid UID of local record (for MM relations - might need to change if support for FlexForms should be done!)
6005
     * @param string $table Table name
6006
     * @return array|null Returns array of items ready to implode for field content.
6007
     * @see remapListedDBRecords()
6008
     * @internal should only be used from within DataHandler
6009
     */
6010
    public function remapListedDBRecords_procDBRefs($conf, $value, $MM_localUid, $table)
6011
    {
6012
        // Initialize variables
6013
        // Will be set TRUE if an upgrade should be done...
6014
        $set = false;
6015
        // Allowed tables for references.
6016
        $allowedTables = $conf['type'] === 'group' ? $conf['allowed'] : $conf['foreign_table'];
6017
        // Table name to prepend the UID
6018
        $prependName = $conf['type'] === 'group' ? ($conf['prepend_tname'] ?? '') : '';
6019
        // Which tables that should possibly not be remapped
6020
        $dontRemapTables = GeneralUtility::trimExplode(',', $conf['dontRemapTablesOnCopy'] ?? '', true);
6021
        // Convert value to list of references:
6022
        $dbAnalysis = $this->createRelationHandlerInstance();
6023
        $dbAnalysis->registerNonTableValues = $conf['type'] === 'select' && ($conf['allowNonIdValues'] ?? false);
6024
        $dbAnalysis->start($value, $allowedTables, $conf['MM'] ?? '', $MM_localUid, $table, $conf);
6025
        // Traverse those references and map IDs:
6026
        foreach ($dbAnalysis->itemArray as $k => $v) {
6027
            $mapID = $this->copyMappingArray_merged[$v['table']][$v['id']] ?? 0;
6028
            if ($mapID && !in_array($v['table'], $dontRemapTables, true)) {
6029
                $dbAnalysis->itemArray[$k]['id'] = $mapID;
6030
                $set = true;
6031
            }
6032
        }
6033
        if (!empty($conf['MM'])) {
6034
            // Purge invalid items (live/version)
6035
            $dbAnalysis->purgeItemArray();
6036
            if ($dbAnalysis->isPurged()) {
6037
                $set = true;
6038
            }
6039
6040
            // If record has been versioned/copied in this process, handle invalid relations of the live record
6041
            $liveId = BackendUtility::getLiveVersionIdOfRecord($table, $MM_localUid);
6042
            $originalId = 0;
6043
            if (!empty($this->copyMappingArray_merged[$table])) {
6044
                $originalId = array_search($MM_localUid, $this->copyMappingArray_merged[$table]);
6045
            }
6046
            if (!empty($liveId) && !empty($originalId) && (int)$liveId === (int)$originalId) {
6047
                $liveRelations = $this->createRelationHandlerInstance();
6048
                $liveRelations->setWorkspaceId(0);
6049
                $liveRelations->start('', $allowedTables, $conf['MM'], $liveId, $table, $conf);
6050
                // Purge invalid relations in the live workspace ("0")
6051
                $liveRelations->purgeItemArray(0);
6052
                if ($liveRelations->isPurged()) {
6053
                    $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

6053
                    $liveRelations->writeMM($conf['MM'], $liveId, /** @scrutinizer ignore-type */ $prependName);
Loading history...
6054
                }
6055
            }
6056
        }
6057
        // If a change has been done, set the new value(s)
6058
        if ($set) {
6059
            if ($conf['MM'] ?? false) {
6060
                $dbAnalysis->writeMM($conf['MM'], $MM_localUid, $prependName);
6061
            } else {
6062
                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

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

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

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