QuickFormQuery.php 89 KB
Newer Older
1
2
3
4
5
6
7
8
<?php
/**
 * Created by PhpStorm.
 * User: ep
 * Date: 12/23/15
 * Time: 6:33 PM
 */

Marc Egger's avatar
Marc Egger committed
9
10
11
12
namespace IMATHUZH\Qfq\Core;

require __DIR__ . '/../../vendor/autoload.php';

13
14
15
16
17
use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Database\DatabaseUpdate;
use IMATHUZH\Qfq\Core\Form\Dirty;
use IMATHUZH\Qfq\Core\Form\DragAndDrop;
use IMATHUZH\Qfq\Core\Form\FormAction;
Marc Egger's avatar
Marc Egger committed
18
use IMATHUZH\Qfq\Core\Form\FormAsFile;
Marc Egger's avatar
Marc Egger committed
19
use IMATHUZH\Qfq\Core\Helper\HelperFile;
20
21
use IMATHUZH\Qfq\Core\Helper\HelperFormElement;
use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser;
Marc Egger's avatar
Marc Egger committed
22
23
use IMATHUZH\Qfq\Core\Helper\Logger;
use IMATHUZH\Qfq\Core\Helper\OnArray;
24
use IMATHUZH\Qfq\Core\Helper\Path;
Marc Egger's avatar
Marc Egger committed
25
use IMATHUZH\Qfq\Core\Helper\Support;
26
27
use IMATHUZH\Qfq\Core\Report\Monitor;
use IMATHUZH\Qfq\Core\Report\Report;
28
use IMATHUZH\Qfq\Core\Report\ReportAsFile;
29
use IMATHUZH\Qfq\Core\Store\Config;
Marc Egger's avatar
Marc Egger committed
30
31
use IMATHUZH\Qfq\Core\Store\FillStoreForm;
use IMATHUZH\Qfq\Core\Store\Session;
32
33
use IMATHUZH\Qfq\Core\Store\Sip;
use IMATHUZH\Qfq\Core\Store\Store;
Marc Egger's avatar
Marc Egger committed
34

35
36
37
38
39
40
41
42
43
44
45
46
/*
 * Form will be called
 * a) with a SIP identifier, or
 * b) without a SIP identifier (form setting has to allow this) and will create on the fly a new SIP.
 *
 * The SIP-Store stores:
 *  form=<formname>
 *  r=<record id>  (table.id for a single record form)
 *  keySemId,keySemIduser
 *  <further individual variables>
 */

Carsten  Rose's avatar
Carsten Rose committed
47
/**
48
 * Class Qfq
Carsten  Rose's avatar
Carsten Rose committed
49
50
 * @package qfq
 */
51
class QuickFormQuery {
52

53
    /**
54
     * @var Store instantiated class
55
     */
Carsten  Rose's avatar
Carsten Rose committed
56
    protected $store = null;
57

58
    /**
Marc Egger's avatar
Marc Egger committed
59
     * @var Database[] - Array of Database instantiated class
60
     */
61
    protected $dbArray = array();
62

63
64
65
    /**
     * @var Evaluate instantiated class
     */
66
67
    protected $evaluate = null;

68
    protected $formSpec = array(); // Stores the form content after parsing SQL queries and QFQ syntax stored in form attributes
69
70
    protected $feSpecAction = array();  // Form Definition: copy of the loaded form
    protected $feSpecNative = array(); // FormEelement Definition: all formElement.class='action' of the loaded form
71
    protected $feSpecNativeRaw = array(); // FormEelement Definition: all formElement.class='action' of the loaded form
72

73
74
75
    /**
     * @var array
     */
76
    private $t3data = array(); // FormElement Definition: all formElement.class='native' of the loaded form
77

78
79
80
    /**
     * @var bool
     */
81
82
    private $phpUnit = false;

83
84
85
86
87
    /**
     * @var bool
     */
    private $inlineReport = false;

88
89
90
91
92
    /**
     * @var Session
     */
    private $session = null;

93
94
95
    private $dbIndexData = false;
    private $dbIndexQfq = false;

96
97
98
99
100
101
102
103
104
105
106
    /*
     * TODO:
     *  Preparation: setup logging, database access, record locking
     *  fill stores
     *  Check permission_create / permission_update
     *  Multi: iterate over all records, Single: activate record
     *      Check mode: Load | Save
     *      doActions 'Before'
     *      Do all FormElements
     *      doActions 'After'
     */
107

108
109
110
    /**
     * Construct the Form Class and Store too. This is the base initialization moment.
     *
111
112
     * As a result of instantiating of Form, the class Store will initially called the first time and therefore
     * instantiated automatically. Store might throw an exception, in case the URL-passed SIP is invalid.
113
     *
114
     * @param array $t3data
Carsten  Rose's avatar
Carsten Rose committed
115
     * @param bool $phpUnit
116
     * @param bool $inlineReport
117
     *
Marc Egger's avatar
Marc Egger committed
118
119
120
121
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
122
     */
123
    public function __construct(array $t3data = array(), $phpUnit = false, $inlineReport = true) {
124
125

        #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}"
126
        $this->phpUnit = $phpUnit;
127
        $this->inlineReport = $inlineReport;
128

129
        mb_internal_encoding("UTF-8");
130

131
132
        $this->session = Session::getInstance($phpUnit);

133
        // Refresh the session even if no new data saved.
134
        Session::set(SESSION_LAST_ACTIVITY, time());
135

136
137
        Support::setQfqErrorHandler();

Carsten  Rose's avatar
Carsten Rose committed
138
139
        // PHPExcel
        set_include_path(get_include_path() . PATH_SEPARATOR . '../../Resources/Private/Classes/');
140

Marc Egger's avatar
Marc Egger committed
141
142
143
144
        // set dummy values if QuickFormQuery is not called by Typo3
        $t3data[T3DATA_BODYTEXT] = $t3data[T3DATA_BODYTEXT] ?? '';
        $t3data[T3DATA_UID] = $t3data[T3DATA_UID] ?? 0;
        $t3data[T3DATA_HEADER] = $t3data[T3DATA_HEADER] ?? '';
Marc Egger's avatar
Marc Egger committed
145

146
        // Read report file, if file keyword exists in bodytext
147
148
        $reportPathFileNameFull = ReportAsFile::parseFileKeyword($t3data[T3DATA_BODYTEXT]);
        if ($reportPathFileNameFull !== null) {
149
            $t3data[T3DATA_BODYTEXT] = ReportAsFile::read_report_file($reportPathFileNameFull);
150
        }
151

152
153
154
155
        // SUPER HACK: to render inline editor when an exception is thrown
        // Can't use store, since store needs bodytext to be parsed, which might throw exceptions if there is a syntax error.
        \UserReportException::$report_uid = $t3data[T3DATA_UID];
        \UserReportException::$report_bodytext = $t3data[T3DATA_BODYTEXT];
156
        \UserReportException::$report_header = $t3data[T3DATA_HEADER];
157
158
        \UserReportException::$report_pathFileName = $reportPathFileNameFull;

159
        $btp = new BodytextParser();
160
        $t3data[T3DATA_BODYTEXT_RAW] = $t3data[T3DATA_BODYTEXT];
161
        $t3data[T3DATA_BODYTEXT] = $btp->process($t3data[T3DATA_BODYTEXT]);
162

163
164
        $this->t3data = $t3data;

165
        $bodytext = $this->t3data[T3DATA_BODYTEXT];
166
167

        $this->store = Store::getInstance($bodytext, $phpUnit);
168

169
170
171
        $timeout = $this->store::getVar(SYSTEM_SESSION_TIMEOUT_SECONDS, STORE_SYSTEM);
        Session::checkSessionExpired($timeout);

172
        // If an FE user logs out and a different user logs in (same browser session) - the old values has to be destroyed!
173
        if (Session::getAndDestroyFlagFeUserHasChanged()) {
174
175
176
            $this->store->unsetStore(STORE_USER);
        }

177
        $this->store->setVar(TYPO3_TT_CONTENT_UID, $t3data[T3DATA_UID], STORE_TYPO3);
178

179
180
        $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM);
        $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM);
181

182
        $this->dbArray[$this->dbIndexData] = new Database($this->dbIndexData);
183

184
185
186
        if ($this->dbIndexData != $this->dbIndexQfq) {
            $this->dbArray[$this->dbIndexQfq] = new Database($this->dbIndexQfq);
        }
187

188
        $this->evaluate = new Evaluate($this->store, $this->dbArray[$this->dbIndexData]);
189

190
        $dbUpdate = $this->store->getVar(SYSTEM_DB_UPDATE, STORE_SYSTEM);
191
        $updateDb = new DatabaseUpdate($this->dbArray[$this->dbIndexQfq], $this->store);
192
        $updateDb->checkNupdate($dbUpdate);
193

194
        $this->store->FillStoreSystemBySql(); // Do this after the DB-update
195
196
197

        // Set dbIndex, evaluate any
        $dbIndex = $this->store->getVar(TOKEN_DB_INDEX, STORE_TYPO3 . STORE_EMPTY);
198
        $dbIndex = $this->evaluate->parse($dbIndex);
199
200
        $dbIndex = ($dbIndex == '') ? DB_INDEX_DEFAULT : $dbIndex;
        $this->store->setVar(TOKEN_DB_INDEX, $dbIndex, STORE_TYPO3);
201

Marc Egger's avatar
Marc Egger committed
202
203
        // Create report file if file keyword not found (and auto export is enabled in qfq settings)
        if ($reportPathFileNameFull === null && $t3data[T3DATA_UID] !== 0 && strtolower($this->store->getVar(SYSTEM_REPORT_AS_FILE_AUTO_EXPORT, STORE_SYSTEM)) === 'yes') {
204
            $reportPathFileNameFull = ReportAsFile::create_file_from_ttContent($t3data[T3DATA_UID], $this->dbArray[$this->dbIndexData]);
205
        }
206
207
208

        // Save pathFileName for use in inline editor
        $this->t3data[T3DATA_REPORT_PATH_FILENAME] = $reportPathFileNameFull;
Carsten  Rose's avatar
Carsten Rose committed
209
210
    }

211
    /**
212
     * Returns the defined forwardMode and set forwardPage
213
     *
214
     * @return array
Marc Egger's avatar
Marc Egger committed
215
216
     * @throws \CodeException
     * @throws \UserFormException
217
     */
218
219
    public function getForwardMode() {

220
        if (!isset($this->formSpec[F_FORWARD_PAGE])) {
Carsten  Rose's avatar
Carsten Rose committed
221
            // For QFQ inline editing: no redirect and no further processing.
222
223
224
            return [API_REDIRECT => API_ANSWER_REDIRECT_NO, API_REDIRECT_URL => ''];
        }

225
        $forwardPage = $this->formSpec[F_FORWARD_PAGE];
226

227
228
229
230
231
232
233
234
235
236
237
238
239
240
        switch ($this->formSpec[F_FORWARD_MODE]) {
            case F_FORWARD_MODE_URL_SIP:
                $forwardPage = store::getSipInstance()->queryStringToSip($forwardPage, RETURN_URL);
                $this->formSpec[F_FORWARD_MODE] = F_FORWARD_MODE_URL;
                break;
            case F_FORWARD_MODE_URL_SIP_SKIP_HISTORY:
                // F_FORWARD_MODE_URL_SIP is not defined in API PROTOCOL. At the moment it's only used for 'copyForm'.
                // 'copyForm' behaves better if the page is not in history.
                // An option for better implementing would be to separate SKIP History from ForwardMode. For API, it can be combined again.
                $forwardPage = store::getSipInstance()->queryStringToSip($forwardPage, RETURN_URL);
                $this->formSpec[F_FORWARD_MODE] = F_FORWARD_MODE_URL_SKIP_HISTORY;
                break;
            default:
                break;
Carsten  Rose's avatar
Carsten Rose committed
241
242
        }

243
244
        return ([
            API_REDIRECT => $this->formSpec[F_FORWARD_MODE],
Carsten  Rose's avatar
Carsten Rose committed
245
            API_REDIRECT_URL => $forwardPage,
246
        ]);
247
248
    }

249
    /**
250
     * Main entry point to display content: a) form and/or b) report
251
     *
252
     * @return string
Marc Egger's avatar
Marc Egger committed
253
254
255
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
256
257
258
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
259
260
261
262
263
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
Carsten  Rose's avatar
Carsten Rose committed
264
     */
265
    public function process() {
266
        $html = '';
267

268
269
270
271
272
        $render = $this->store->getVar(SYSTEM_RENDER, STORE_TYPO3 . STORE_SYSTEM);
        if ($render == SYSTEM_RENDER_API && isset($GLOBALS['TYPO3_CONF_VARS'])) {
            return '';
        }

273
        if ($this->store->getVar(TYPO3_DEBUG_SHOW_BODY_TEXT, STORE_TYPO3) === 'yes') {
274
275
            $htmlId = HelperFormElement::buildFormElementId($this->formSpec[F_ID], 0, 0, 0);
            $html .= Support::doTooltip($htmlId . HTML_ID_EXTENSION_TOOLTIP, $this->t3data['bodytext']);
276
277
278
        }

        $html .= $this->doForm(FORM_LOAD);
279
280
281
        if ($render == SYSTEM_RENDER_BOTH || $render == SYSTEM_RENDER_API || ($render == SYSTEM_RENDER_SINGLE && $html == '')) {
            $html .= $this->doReport();
        }
Carsten  Rose's avatar
Carsten Rose committed
282

283
        // Only needed if there are potential 'download'-links, which shall show a popup during processing of the download.
Carsten  Rose's avatar
Carsten Rose committed
284
285
286
287
        if ($this->store->getVar(SYSTEM_DOWNLOAD_POPUP, STORE_SYSTEM) == DOWNLOAD_POPUP_REQUEST) {
            $html .= $this->getModalCode();
        }

288
289
290
291
292
        // Only needed if there are 'drag and drop' elements.
        if ($this->store->getVar(SYSTEM_DRAG_AND_DROP_JS, STORE_SYSTEM) == 'true') {
            $html .= $this->getDragAndDropCode();
        }

293
        $class = $this->store->getVar(SYSTEM_CSS_CLASS_QFQ_CONTAINER, STORE_SYSTEM);
Carsten  Rose's avatar
Carsten Rose committed
294
        if ($class) {
295
            $html = Support::wrapTag("<div class='$class'>", $html);
Carsten  Rose's avatar
Carsten Rose committed
296
297
        }

298
        return $html;
299
300
    }

301
    /**
302
     * Determine the name of the language parameter field, which has to be taken to fill language specific definitions.
303
     *
Marc Egger's avatar
Marc Egger committed
304
305
     * @throws \CodeException
     * @throws \UserFormException
306
307
308
309
310
311
312
313
314
     */
    private function setParameterLanguageFieldName() {

        $typo3PageLanguage = $this->store->getVar(TYPO3_PAGE_LANGUAGE, STORE_TYPO3);
        if (empty($typo3PageLanguage)) {
            return;
        }

        foreach (['A', 'B', 'C', 'D'] as $key) {
315
            $languageIdx = SYSTEM_FORM_LANGUAGE . "$key" . "Id";
316
317
318
319
320
321
322
            if ($this->store->getVar($languageIdx, STORE_SYSTEM) == $typo3PageLanguage) {
                $this->store->setVar(SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME, 'parameterLanguage' . $key, STORE_SYSTEM);
                break;
            }
        }
    }

323
    /**
324
325
     * Creates an empty file. This indicates that the current form is in debug mode. Returns HTML element which will be
     * replaced by the logfile.
326
327
328
     *
     * @param $formName
     * @param $formLogMode
329
     * @return string
Marc Egger's avatar
Marc Egger committed
330
331
332
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
333
334
335
336
     */
    private function getFormLog($formName, $formLogMode) {

        $formLogFileName = Support::getFormLogFileName($formName, $formLogMode);
337
        file_put_contents($formLogFileName, '');
338

339
340
        $monitor = new Monitor();

341
342
        return "<pre id='" . FORM_LOG_HTML_ID . "'>Please wait</pre>" .
            $monitor->process([TOKEN_L_FILE => $formLogFileName, TOKEN_L_APPEND => '1', TOKEN_L_HTML_ID => FORM_LOG_HTML_ID]);
343
    }
344

345
    /**
346
     * Process form.
347
348
349
350
351
     * $mode=
     *   FORM_LOAD: The whole form will be rendered as HTML Code, including the values of all form elements
     *   FORM_UPDATE: States and values of all form elements will be returned as JSON.
     *   FORM_SAVE: The submitted form will be saved. Return Failure or Success as JSON.
     *   FORM_DELETE:
352
     *
353
     * @param string $formMode FORM_LOAD | FORM_UPDATE | FORM_SAVE | FORM_DELETE
354
     *
355
     * @return array|string
Marc Egger's avatar
Marc Egger committed
356
357
358
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
Carsten  Rose's avatar
Carsten Rose committed
359
360
361
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
362
363
364
365
366
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
367
     */
368
    private function doForm($formMode) {
Carsten  Rose's avatar
Carsten Rose committed
369
        $data = '';
Carsten  Rose's avatar
Carsten Rose committed
370
        $foundInStore = '';
371
        $flagApiStructureReGroup = true;
372
        $formModeNew = '';
373
        $build = null;
374

375
        $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP . STORE_TYPO3 . STORE_CLIENT . STORE_ZERO, SANITIZE_ALLOW_DIGIT, $foundInStore);
376
        $this->setParameterLanguageFieldName();
377

378
379
380
        $formName = $this->loadFormSpecification($formMode, $recordId, $foundInStore, $formLogMode);
        if ($formName !== false && $formLogMode !== false) {
            return $this->getFormLog($formName, $formLogMode);
381
382
        }

383
        if ($formName === false) {
384
            switch ($formMode) {
385
                case FORM_DELETE:
386
                    $formModeNew = FORM_DELETE;
387
388
                    break;
                case FORM_DRAG_AND_DROP:
Marc Egger's avatar
Marc Egger committed
389
                    throw new \CodeException('Missing form in SIP', ERROR_MISSING_FORM);
390
391
392
                default:
                    return '';// No form found: do nothing
            }
393
        }
394

Carsten  Rose's avatar
Carsten Rose committed
395
        // Check 'session expire' happens quite late, cause it can be configured per form.
396
397
398
        if ($formName !== false) {
            Session::checkSessionExpired($this->formSpec[F_SESSION_TIMEOUT_SECONDS]);
        }
399

400
401
402
403
404
405
406
        // Fill STORE_FORM: might need Form.fillStoreVar={{!SELECT ...}}) to provide STORE_VAR - therefore the FORM-definition should already been processed. #8058
        switch ($formMode) {
            case FORM_UPDATE:
            case FORM_SAVE:
            case FORM_REST:
                $fillStoreForm = new FillStoreForm();
                $fillStoreForm->process($formMode);
407
408
409
410
411
412

                // STORE_TYPO3 has been filled: fire fillStoreVar again.
                if (!empty($this->formSpec[FE_FILL_STORE_VAR])) {
                    $this->fillStoreVar($this->formSpec[FE_FILL_STORE_VAR]);
                }

413
414
415
                break;
        }

416
        if ($formName !== false) {
Carsten  Rose's avatar
Carsten Rose committed
417
            // Validate (only if there is a 'real' form, not a FORM_DELETE with only a table name).
Carsten  Rose's avatar
Carsten Rose committed
418
419
            // Attention: $formModeNew will be set
            $sipFound = $this->validateForm($foundInStore, $formMode, $formModeNew);
420
421

        } else {
422
            // FORM_DELETE without a form definition: Fake the form with only a tableName.
423
424
            $table = $this->store->getVar(SIP_TABLE, STORE_SIP);
            if ($table === false) {
Marc Egger's avatar
Marc Egger committed
425
                throw new \UserFormException("No 'form' and no 'table' definition found.", ERROR_MISSING_VALUE);
426
            }
427

428
429
430
            $sipFound = true;
            $this->formSpec[F_NAME] = '';
            $this->formSpec[F_TABLE_NAME] = $table;
431
432
            $this->formSpec[F_RECORD_LOCK_TIMEOUT_SECONDS] = 1; // just indicate a timeout, the exact timeout is stored in the dirty record.
            $this->formSpec[F_DIRTY_MODE] = DIRTY_MODE_EXCLUSIVE; // just set a mode,, the exact mode is stored in the dirty record.
433
            $this->formSpec[F_PRIMARY_KEY] = F_PRIMARY_KEY_DEFAULT;
434
435
436
437
438
439
440
441
442
443

            $tmpDbIndexData = $this->store->getVar(PARAM_DB_INDEX_DATA, STORE_SIP);
            if (!empty($tmpDbIndexData)) {
                $this->formSpec[F_DB_INDEX] = $tmpDbIndexData;
                if ($tmpDbIndexData != $this->dbIndexData) {
                    if (!isset($this->dbArray[$tmpDbIndexData])) {
                        $this->dbArray[$tmpDbIndexData] = new Database($tmpDbIndexData);
                    }
                }
            }
444
445
        }

446
        // FormAsFile: Get the new and the old form file name and make sure both files are writable (before DB changes are made)
447
        // Note: This can't be done earlier because $formModeNew might be changed in the lines above.
448
449
        $formNameDB = FormAsFile::formNameFromFormRelatedRecord($recordId, $this->formSpec[F_TABLE_NAME] ?? '', $this->dbArray[$this->dbIndexQfq]);
        switch ($this->formSpec[F_TABLE_NAME] ?? '') {
450
451
            case TABLE_NAME_FORM: // cases covered: new form, existing form, existing form but form name changed
                $formFileName = $this->store->getVar(F_NAME, STORE_FORM, SANITIZE_ALLOW_ALNUMX);
452
453
454
                $formFileName = $formFileName === false ? $formNameDB : $formFileName;
                if ($formNameDB !== null && $formFileName !== $formNameDB && $formModeNew === FORM_SAVE) {
                    $formFileNameDelete = $formNameDB;
455
                    FormAsFile::enforceFormFileWritable($formFileNameDelete, $this->dbArray[$this->dbIndexQfq]); // file will be deleted after DB changes
456
457
458
459
                }
                break;
            case TABLE_NAME_FORM_ELEMENT: // cases covered: new formElement, existing formElement
                $formId = $this->store->getVar(FE_FORM_ID, STORE_FORM);
460
                $formFileName = $formId !== false ? FormAsFile::formNameFromFormRelatedRecord($formId, TABLE_NAME_FORM, $this->dbArray[$this->dbIndexQfq]) : $formNameDB;
461
462
                break;
            default:
463
                $formFileName = $formNameDB;
464
        }
465
        if ($formFileName !== null && in_array($formModeNew, [FORM_SAVE, FORM_DRAG_AND_DROP, FORM_DELETE])) {
466
            FormAsFile::enforceFormFileWritable($formFileName, $this->dbArray[$this->dbIndexQfq]);
467
468
        }

469
470
        // For 'new' record always create a new Browser TAB-uniq (for this current form, nowhere else used) SIP.
        // With such a Browser TAB-uniq SIP, multiple Browser TABs and following repeated NEWs are easily implemented.
471
472
473
474
        if ($formMode != FORM_REST) {
            if (!$sipFound || ($formMode == FORM_LOAD && $recordId === 0)) {
                $this->store->createSipAfterFormLoad($formName);
            }
475
        }
476

477
        // Fill STORE_BEFORE
478
        if ($formName !== false && $this->store->getVar($this->formSpec[F_PRIMARY_KEY], STORE_BEFORE) === false) {
479
            $this->store->fillStoreWithRecord($this->formSpec[F_TABLE_NAME], $recordId,
480
                $this->dbArray[$this->dbIndexData], $this->formSpec[F_PRIMARY_KEY], STORE_BEFORE);
481
482
        }

483
        // Check (and release) dirtyRecord.
Carsten  Rose's avatar
Carsten Rose committed
484
        if ($formModeNew === FORM_DELETE || $formModeNew === FORM_SAVE) {
485
            $dirty = new Dirty(false, $this->dbIndexData, $this->dbIndexQfq);
Carsten  Rose's avatar
Carsten Rose committed
486

Carsten  Rose's avatar
Carsten Rose committed
487
            $answer = $dirty->checkDirtyAndRelease($formModeNew, $this->formSpec[F_RECORD_LOCK_TIMEOUT_SECONDS],
488
                $this->formSpec[F_DIRTY_MODE], $this->formSpec[F_TABLE_NAME], $this->formSpec[F_PRIMARY_KEY], $recordId, true);
489

490
            // In case of a conflict, return immediately
Carsten  Rose's avatar
Carsten Rose committed
491
492
            if ($answer[API_STATUS] != API_ANSWER_STATUS_SUCCESS) {
                $answer[API_STATUS] = API_ANSWER_STATUS_ERROR;
493

494
495
                return $answer;
            }
Carsten  Rose's avatar
Carsten Rose committed
496
497
        }

498
        // FORM_LOAD: if there is a foreign exclusive record lock - show form in F_MODE_READONLY mode.
Carsten  Rose's avatar
Carsten Rose committed
499
        if ($formModeNew === FORM_LOAD) {
500
            $dirty = new Dirty(false, $this->dbIndexData, $this->dbIndexQfq);
501
502
503
            $recordDirty = array();
            $rcLockFound = $dirty->getCheckDirty($this->formSpec[F_TABLE_NAME], $recordId, $recordDirty, $msg);
            if (($rcLockFound == LOCK_FOUND_CONFLICT || $rcLockFound == LOCK_FOUND_OWNER) && $recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE) {
504
                $this->formSpec[F_MODE_GLOBAL] = F_MODE_READONLY;
505
506
507
            }
        }

Carsten  Rose's avatar
Carsten Rose committed
508
        switch ($formModeNew) {
509
510
511
512
513
514
515
516
517
            case FORM_DELETE:
                $build = new Delete($this->dbIndexData);
                break;
            case FORM_REST:
                break;
            case FORM_LOAD:
            case FORM_SAVE:
            case FORM_UPDATE:
            case FORM_DRAG_AND_DROP:
518

519
520
521
                $tableDefinition = $this->dbArray[$this->dbIndexData]->getTableDefinition($this->formSpec[F_TABLE_NAME]);
                $this->store->fillStoreTableDefaultColumnType($tableDefinition);

522
523
                // Check if the defined column primary key exist.
                if ($this->store::getVar($this->formSpec[F_PRIMARY_KEY], STORE_TABLE_COLUMN_TYPES) === false) {
524
                    throw new \UserFormException("Primary Key '" . $this->formSpec[F_PRIMARY_KEY] . "' not found in table " . $this->formSpec[F_TABLE_NAME], ERROR_INVALID_OR_MISSING_PARAMETER);
525
526
                }

527
528
529
530
531
532
533
534
535
536
537
                switch ($this->formSpec['render']) {
                    case 'plain':
                        $build = new BuildFormPlain($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
                        break;
                    case 'table':
                        $build = new BuildFormTable($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
                        break;
                    case 'bootstrap':
                        $build = new BuildFormBootstrap($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->dbArray);
                        break;
                    default:
Marc Egger's avatar
Marc Egger committed
538
                        throw new \CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
539
540
541
                }
                break;
            default:
Marc Egger's avatar
Marc Egger committed
542
                throw new \CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
543
544
        }

545
        $formAction = new FormAction($this->formSpec, $this->dbArray[$this->dbIndexData], $this->phpUnit);
Carsten  Rose's avatar
Carsten Rose committed
546
        switch ($formModeNew) {
547
            case FORM_LOAD:
548
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_LOAD);
549
550

                // Build FORM
Carsten  Rose's avatar
Carsten Rose committed
551
                $data = $build->process($formModeNew);
552

553
554
555
556
                $tmpClass = is_numeric($this->formSpec[F_BS_COLUMNS]) ? ('col-md-' . $this->formSpec[F_BS_COLUMNS]) : $this->formSpec[F_BS_COLUMNS];
//                $data = Support::wrapTag("<div class='" . 'col-md-' . $this->formSpec[F_BS_COLUMNS] . "'>", $data);
                $data = Support::wrapTag('<div class="' . $tmpClass . '">', $data);
                $data = Support::wrapTag('<div class="row">', $data);
557
558
559
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
                break;

Carsten  Rose's avatar
Carsten Rose committed
560
            case FORM_UPDATE:
561
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_LOAD);
562
                // data['form-update']=....
Carsten  Rose's avatar
Carsten Rose committed
563
                $data = $build->process($formModeNew);
564
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
565
                break;
Carsten  Rose's avatar
Carsten Rose committed
566

567
568
569
            case FORM_DELETE:
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_DELETE);

570
                $build->process($this->formSpec[F_TABLE_NAME], $recordId, $this->formSpec[F_PRIMARY_KEY]);
571
572
573
574

                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_DELETE);
                break;

575
            case FORM_SAVE:
576
577
                $this->logFormSubmitRequest();

Carsten  Rose's avatar
Carsten Rose committed
578
                $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP . STORE_TYPO3);
579

580
                // Action: Before
581
582
                $feTypeList = FE_TYPE_BEFORE_SAVE . ',' . ($recordId == 0 ? FE_TYPE_BEFORE_INSERT : FE_TYPE_BEFORE_UPDATE);
                $formAction->elements($recordId, $this->feSpecAction, $feTypeList);
583

584
                // If an old record exist: load it. Necessary to delete uploaded files which should be overwritten.
585
                $this->store->fillStoreWithRecord($this->formSpec[F_TABLE_NAME], $recordId,
586
                    $this->dbArray[$this->dbIndexData], $this->formSpec[F_PRIMARY_KEY]);
587

588
589
                $this->ifPillIsHiddenSetChildFeToHidden();

590
                // SAVE
591
                $save = new Save($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->feSpecNativeRaw);
592
593

                $save->processAllImageCutFE();
594
                $save->checkRequiredHidden();
595

596
597
                $rc = $save->process();

Carsten  Rose's avatar
Upload:    
Carsten Rose committed
598
599
                $save->processAllUploads($rc);

600
601
                // Action: After*, Sendmail
                $feTypeList = FE_TYPE_SENDMAIL . ',' . FE_TYPE_AFTER_SAVE . ',' . ($recordId == 0 ? FE_TYPE_AFTER_INSERT : FE_TYPE_AFTER_UPDATE);
602
                $status = $formAction->elements($rc, $this->feSpecAction, $feTypeList);
603
                if ($status != ACTION_ELEMENT_NO_CHANGE) {
604
                    // Reload fresh saved record and fill STORE_RECORD with it.
605
                    $this->store->fillStoreWithRecord($this->formSpec[F_TABLE_NAME], $rc, $this->dbArray[$this->dbIndexData], $this->formSpec[F_PRIMARY_KEY]);
606
                }
607

Carsten  Rose's avatar
Carsten Rose committed
608
609
610
                // Action: Paste
                $this->pasteClipboard($this->formSpec[F_ID], $formAction);

Carsten  Rose's avatar
Carsten Rose committed
611
                if ($formMode == FORM_REST) {
612
                    $data = $this->doRestPostPut($rc);
613
                    $flagApiStructureReGroup = false;
Carsten  Rose's avatar
Carsten Rose committed
614
615
                    break;
                }
616

617
                $this->setForwardModePage();
618
619

                // Logic: If a) r=0 and
620
                //           b) final: (forwardMode=='auto' and User presses only 'save' (not 'save & close')) OR (forwardMode=='no')
621
                // then the client should reload the current page with the newly created record. A new SIP is necessary!
622
                $getJson = true;
623
624
625
626
                if (0 == $this->store->getVar(SIP_RECORD_ID, STORE_SIP)
                    && (($this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_AUTO
                            && API_SUBMIT_REASON_SAVE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX)
                        ) || $this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_NO)) {
627
                    $this->formSpec = $this->buildNSetReloadUrl($this->formSpec, $rc);
628
                    $getJson = false;
629
                }
630

631
                if ($getJson) {
632

633
                    // Values of FormElements might be changed during 'afterSave': rebuild the form to load the new values. Especially for non primary template groups.
634
635
636
637
                    $feSpecNative = $this->getNativeFormElements(SQL_FORM_ELEMENT_NATIVE_TG_COUNT, [$this->formSpec[F_ID]], $this->formSpec);
                    $parameterLanguageFieldName = $this->store->getVar(SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME, STORE_SYSTEM);
                    $feSpecNative = HelperFormElement::setLanguage($feSpecNative, $parameterLanguageFieldName);
                    $this->feSpecNative = HelperFormElement::setFeContainerFormElementId($feSpecNative, $this->formSpec[F_ID], $recordId);
638

Carsten  Rose's avatar
Carsten Rose committed
639
                    $data = $build->process($formModeNew, false, $this->feSpecNative);
640
                }
641
                break;
642

643
644
645
            case FORM_DRAG_AND_DROP:
                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_BEFORE_LOAD);

646
                $dragAndDrop = new DragAndDrop($this->formSpec);
647
                $data = $dragAndDrop->process();
648
                $flagApiStructureReGroup = false;
649
650

                $formAction->elements($recordId, $this->feSpecAction, FE_TYPE_AFTER_LOAD);
651
                break;
Carsten  Rose's avatar
Carsten Rose committed
652

653
            case FORM_REST:
654
                $flagApiStructureReGroup = false;
655
                $data = $this->doRestGet();
656
657
                break;

658
            default:
Marc Egger's avatar
Marc Egger committed
659
                throw new \CodeException("This statement should never be reached", ERROR_CODE_SHOULD_NOT_HAPPEN);
660
661
        }

662
        if ($flagApiStructureReGroup && is_array($data)) {
663
            // $data['element-update']=...
664
665
            $data = $this->groupElementUpdateEntries($data);
        }
Carsten  Rose's avatar
Carsten Rose committed
666

667
        // export Form to file, if loaded record is a Form/FormElement
668
        if ($formFileName !== null) {
669
670
671
            switch ($formModeNew) {
                case FORM_SAVE:
                case FORM_DRAG_AND_DROP:
672
                    FormAsFile::exportForm($formFileName, $this->dbArray[$this->dbIndexQfq]);
673
674
                    break;
                case FORM_DELETE:
675
                    if (TABLE_NAME_FORM_ELEMENT === ($this->formSpec[F_TABLE_NAME] ?? '')) {
676
                        FormAsFile::exportForm($formFileName, $this->dbArray[$this->dbIndexQfq]);
677
                    } else {
678
                        FormAsFile::deleteFormFile($formFileName, $this->dbArray[$this->dbIndexQfq], 'Form was deleted using form-editor.');
679
                    }
680
                    break;
681
            }
682
683
        }

684
685
        // delete old form file if form name was changed
        if (isset($formFileNameDelete)) {
686
            FormAsFile::deleteFormFile($formFileNameDelete, $this->dbArray[$this->dbIndexQfq], "Form was renamed to: '$formFileName'.");
687
688
        }

Carsten  Rose's avatar
Carsten Rose committed
689
        return $data;
690
691
    }

692
693
    /**
     * @return array
Marc Egger's avatar
Marc Egger committed
694
695
696
697
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
698
     */
699
    private function doRestGet() {
700

Carsten  Rose's avatar
Carsten Rose committed
701
        $this->nameGenericRestParam();
702
703
704
705

        $r = $this->store::getVar(TYPO3_RECORD_ID, STORE_TYPO3);
        $key = empty($r) ? F_REST_SQL_LIST : F_REST_SQL_DATA;

706
        if (!isset($this->formSpec[$key])) {
Marc Egger's avatar
Marc Egger committed
707
            throw new \UserFormException("Missing Parameter '$key'", ERROR_INVALID_VALUE);
708
709
710
        }

        return $this->evaluate->parse($this->formSpec[$key]);
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
    }

    /**
     * @return bool|array
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function doRestPostPut($id) {

        if (!isset($this->formSpec[F_REST_SQL_POST_PUT])) {
            return ['id' => $id];
        }

        $this->nameGenericRestParam();
727

728
        return $this->evaluate->parse($this->formSpec[F_REST_SQL_POST_PUT]);
729
730
    }

Carsten  Rose's avatar
Carsten Rose committed
731
732
733
734
735
    /**
     * Checks if $serverToken matches HTTP_HEADER_AUTHORIZATION,
     * If not: throw an exception.
     *
     * @param string|array $serverToken
Marc Egger's avatar
Marc Egger committed
736
737
     * @throws \CodeException
     * @throws \UserFormException
Carsten  Rose's avatar
Carsten Rose committed
738
739
740
741
742
743
744
745
     */
    private function restCheckAuthToken($serverToken) {

        // No serverToken: no check necessary
        if ($serverToken === '') {
            return;
        }

746
747
        $clientToken = $this->store::getVar(HTTP_HEADER_AUTHORIZATION, STORE_CLIENT, SANITIZE_ALLOW_ALL);
        if ($serverToken === $clientToken) {
Carsten  Rose's avatar
Carsten Rose committed
748
749
750
751
752
753
754
            return;
        }

        // Delay before answering.
        $seconds = $this->store::getVar(SYSTEM_SECURITY_FAILED_AUTH_DELAY, STORE_SYSTEM);
        sleep($seconds);

755
        if ($clientToken == false) {
Marc Egger's avatar
Marc Egger committed
756
            throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'Missing authorization token',
Marc Egger's avatar
Marc Egger committed
757
                ERROR_MESSAGE_TO_DEVELOPER => "Missing HTTP Header: " . HTTP_HEADER_AUTHORIZATION,
758
                ERROR_MESSAGE_HTTP_STATUS => HTTP_401_UNAUTHORIZED
759
760
761
            ]), ERROR_REST_AUTHORIZATION);
        }

Marc Egger's avatar
Marc Egger committed
762
        throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => 'Authorization token not accepted',
Marc Egger's avatar
Marc Egger committed
763
            ERROR_MESSAGE_TO_DEVELOPER => "Missing HTTP Header: " . HTTP_HEADER_AUTHORIZATION,
764
            ERROR_MESSAGE_HTTP_STATUS => HTTP_401_UNAUTHORIZED
765
        ]), ERROR_REST_AUTHORIZATION);
Carsten  Rose's avatar
Carsten Rose committed
766
767
    }

768
769
770
    /**
     * STORE_CLIENT: copy parameter _id1,_id2,...,_idN to named variables, specified via $this->formSpec[F_REST_PARAM] (CSV list)
     *
Marc Egger's avatar
Marc Egger committed
771
772
     * @throws \CodeException
     * @throws \UserFormException
773
     */
Carsten  Rose's avatar
Carsten Rose committed
774
    private function nameGenericRestParam() {
775
776
777
778
779
780
781
782

        $paramNames = explode(',', $this->formSpec[F_REST_PARAM] ?? '');

        $ii = 1;
        foreach ($paramNames as $key) {
            switch ($key) {
                case CLIENT_FORM:
                case CLIENT_RECORD_ID:
Marc Egger's avatar
Marc Egger committed
783
                    throw new \UserFormException("Name '$key' is forbidden in " . F_REST_PARAM, ERROR_INVALID_VALUE);
784
785
786
787
788
789
790
791
792
793
                    break;
                default:
                    break;
            }
            $val = $this->store::getVar(CLIENT_REST_ID . $ii, STORE_CLIENT);
            $this->store::setVar($key, $val, STORE_CLIENT);
            $ii++;
        }
    }

794
795
796
    /**
     * Copies state 'hidden' from a FE pill to all FE child elements of that pill.
     *
Marc Egger's avatar
Marc Egger committed
797
798
799
800
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
801
802
803
804
805
806
     */
    private function ifPillIsHiddenSetChildFeToHidden() {

        $feFilter = OnArray::filter($this->feSpecNative, FE_TYPE, FE_TYPE_PILL);

        if (!empty($feFilter)) {
807
            foreach ($feFilter as $feParent) {
808

809
                if ($feParent[FE_MODE_SQL]) {
810
                    $mode = $this->evaluate->parse($feParent[FE_MODE_SQL]);
811
812
                    if ($mode != '') {
                        $feParent[FE_MODE] = $mode;
813
814
815
816
817
                    }
                }

                if ($feParent[FE_MODE] == FE_MODE_HIDDEN) {
                    $feChild = OnArray::filter($this->feSpecNative, FE_ID_CONTAINER, $feParent[FE_ID]);
818
                    foreach ($feChild as $fe) {
819
820

                        # Search for origin
821
822
                        foreach ($this->feSpecNative as $key => $value) {
                            if ($value[FE_ID] == $fe[FE_ID]) {
823
824
825
826
827
828
829
830
831
832
                                $this->feSpecNative[$key][FE_MODE] = FE_MODE_HIDDEN;
                                break;
                            }
                        }
                    }
                }
            }
        }
    }

833
    /**
Marc Egger's avatar
Marc Egger committed
834
835
836
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
837
838
839
840
841
842
843
844
845
846
847
     */
    private function logFormSubmitRequest() {
        $formSubmitLogMode = $this->formSpec[F_FORM_SUBMIT_LOG_MODE] ??
            $this->store->getVar(SYSTEM_FORM_SUBMIT_LOG_MODE, STORE_SYSTEM, SANITIZE_ALLOW_ALNUMX);
        if ($formSubmitLogMode === FORM_SUBMIT_LOG_MODE_NONE) {
            return;
        }

        $formData = $_POST;
        unset($formData[CLIENT_SIP]);
        $formData = json_encode($formData, JSON_UNESCAPED_UNICODE);
Carsten  Rose's avatar
Carsten Rose committed
848
849
        $clientIp = $_SERVER[CLIENT_REMOTE_ADDRESS] ?? '';
        $userAgent = $_SERVER[CLIENT_HTTP_USER_AGENT] ?? '';
850
851
852
853
854
855
856
        $sipData = json_encode($this->store->getStore(STORE_SIP), JSON_UNESCAPED_UNICODE);
        $formId = $this->formSpec[F_ID];
        $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP);
        $feUser = $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX);
        $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX);
        $sessionId = session_id();

857
        $sql = "INSERT INTO `FormSubmitLog` (`formData`, `sipData`, `clientIp`, `feUser`, `userAgent`, `formId`, `recordId`, `pageId`, `sessionId`, `created`)" .
858
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())";
859
860
861
862
        $params = [$formData, $sipData, $clientIp, $feUser, $userAgent, $formId, $recordId, $pageId, $sessionId];
        $this->dbArray[$this->dbIndexQfq]->sql($sql, ROW_REGULAR, $params);
    }

863
864
865
866
867
868
869
870
871
872
873

    /**
     * Check if forwardMode='url...'.
     * yes: process 'forwardPage' and fill $this->formSpec[F_FORWARD_MODE] and $this->formSpec[F_FORWARD_PAGE]
     * no: do nothing
     *
     * '$this->formSpec[F_FORWARD_PAGE]' might give a new forwardMode. If so, set $this->formSpec[F_FORWARD_MODE] to
     * it.
     *
     * '$this->formSpec[F_FORWARD_PAGE]':
     * a) url     http://www.nzz.ch/index.html?a=123#bottom, website.html?a=123#bottom,
Carsten  Rose's avatar