390ea7b2be432329a6b8502b9066f4111dcb4200
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Controller / Wizard / SuggestWizardController.php
1 <?php
2 namespace TYPO3\CMS\Backend\Controller\Wizard;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use Psr\Http\Message\ResponseInterface;
18 use Psr\Http\Message\ServerRequestInterface;
19 use TYPO3\CMS\Backend\Form\Wizard\SuggestWizardDefaultReceiver;
20 use TYPO3\CMS\Backend\Utility\BackendUtility;
21 use TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools;
22 use TYPO3\CMS\Core\Database\ConnectionPool;
23 use TYPO3\CMS\Core\Utility\ArrayUtility;
24 use TYPO3\CMS\Core\Utility\GeneralUtility;
25 use TYPO3\CMS\Core\Utility\MathUtility;
26
27 /**
28 * Receives ajax request from FormEngine suggest wizard and creates suggest answer as json result
29 */
30 class SuggestWizardController
31 {
32 /**
33 * Ajax handler for the "suggest" feature in FormEngine.
34 *
35 * @param ServerRequestInterface $request
36 * @param ResponseInterface $response
37 * @throws \RuntimeException for incomplete or invalid arguments
38 * @return ResponseInterface
39 */
40 public function searchAction(ServerRequestInterface $request, ResponseInterface $response)
41 {
42 $parsedBody = $request->getParsedBody();
43
44 if (!isset($parsedBody['value'])
45 || !isset($parsedBody['table'])
46 || !isset($parsedBody['field'])
47 || !isset($parsedBody['uid'])
48 || !isset($parsedBody['dataStructureIdentifier'])
49 || !isset($parsedBody['hmac'])
50 ) {
51 throw new \RuntimeException(
52 'Missing at least one of the required arguments "value", "table", "field", "uid"'
53 . ', "dataStructureIdentifier" or "hmac"',
54 1478607036
55 );
56 }
57
58 $search = $parsedBody['value'];
59 $table = $parsedBody['table'];
60 $field = $parsedBody['field'];
61 $uid = $parsedBody['uid'];
62 $pid = (int)$parsedBody['pid'];
63
64 // flex form section container identifiers are created on js side dynamically "onClick". Those are
65 // not within the generated hmac ... the js side adds "idx{dateInMilliseconds}-", so this is removed here again.
66 // example outgoing in renderSuggestSelector():
67 // flex_1|data|sSuggestCheckCombination|lDEF|settings.subelements|el|ID-356586b0d3-form|item|el|content|vDEF
68 // incoming here:
69 // flex_1|data|sSuggestCheckCombination|lDEF|settings.subelements|el|ID-356586b0d3-idx1478611729574-form|item|el|content|vDEF
70 // Note: For existing containers, these parts are numeric, so "ID-356586b0d3-idx1478611729574-form" becomes 1 or 2, etc.
71 // @todo: This could be kicked is the flex form section containers are moved to an ajax call on creation
72 $fieldForHmac = preg_replace('/idx\d{13}-/', '', $field);
73
74 $dataStructureIdentifierString = '';
75 if (!empty($parsedBody['dataStructureIdentifier'])) {
76 $dataStructureIdentifierString = json_encode($parsedBody['dataStructureIdentifier']);
77 }
78
79 $incomingHmac = $parsedBody['hmac'];
80 $calculatedHmac = GeneralUtility::hmac(
81 $table . $fieldForHmac . $uid . $pid . $dataStructureIdentifierString,
82 'formEngineSuggest'
83 );
84 if ($incomingHmac !== $calculatedHmac) {
85 throw new \RuntimeException(
86 'Incoming and calculated hmac do not match',
87 1478608245
88 );
89 }
90
91 // If the $uid is numeric (existing page) and a suggest wizard in pages is handled, the effective
92 // pid is the uid of that page - important for page ts config configuration.
93 if (MathUtility::canBeInterpretedAsInteger($uid) && $table === 'pages') {
94 $pid = $uid;
95 }
96 $TSconfig = BackendUtility::getPagesTSconfig($pid);
97
98 // Determine TCA config of field
99 if (empty($dataStructureIdentifierString)) {
100 // Normal columns field
101 $fieldConfig = $GLOBALS['TCA'][$table]['columns'][$field]['config'];
102 } else {
103 // A flex flex form field
104 $flexFormTools = GeneralUtility::makeInstance(FlexFormTools::class);
105 $dataStructureArray = $flexFormTools->parseDataStructureByIdentifier($dataStructureIdentifierString);
106 $parts = explode('|', $field);
107 $fieldConfig = $this->getFlexFieldConfiguration($parts, $dataStructureArray);
108 // Flexform field name levels are separated with | instead of encapsulation in [];
109 // reverse this here to be compatible with regular field names.
110 $field = str_replace('|', '][', $field);
111 }
112
113 $wizardConfig = $fieldConfig['wizards']['suggest'];
114
115 $queryTables = $this->getTablesToQueryFromFieldConfiguration($fieldConfig);
116 $whereClause = $this->getWhereClause($fieldConfig);
117
118 $resultRows = [];
119
120 // fetch the records for each query table. A query table is a table from which records are allowed to
121 // be added to the TCEForm selector, originally fetched from the "allowed" config option in the TCA
122 foreach ($queryTables as $queryTable) {
123 // if the table does not exist, skip it
124 if (!is_array($GLOBALS['TCA'][$queryTable]) || empty($GLOBALS['TCA'][$queryTable])) {
125 continue;
126 }
127
128 $config = $this->getConfigurationForTable($queryTable, $wizardConfig, $TSconfig, $table, $field);
129
130 // process addWhere
131 if (!isset($config['addWhere']) && $whereClause) {
132 $config['addWhere'] = $whereClause;
133 }
134 if (isset($config['addWhere'])) {
135 $replacement = [
136 '###THIS_UID###' => (int)$uid,
137 '###CURRENT_PID###' => (int)$pid
138 ];
139 if (isset($TSconfig['TCEFORM.'][$table . '.'][$field . '.'])) {
140 $fieldTSconfig = $TSconfig['TCEFORM.'][$table . '.'][$field . '.'];
141 if (isset($fieldTSconfig['PAGE_TSCONFIG_ID'])) {
142 $replacement['###PAGE_TSCONFIG_ID###'] = (int)$fieldTSconfig['PAGE_TSCONFIG_ID'];
143 }
144 if (isset($fieldTSconfig['PAGE_TSCONFIG_IDLIST'])) {
145 $replacement['###PAGE_TSCONFIG_IDLIST###'] = implode(',', GeneralUtility::intExplode(',', $fieldTSconfig['PAGE_TSCONFIG_IDLIST']));
146 }
147 if (isset($fieldTSconfig['PAGE_TSCONFIG_STR'])) {
148 $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($fieldConfig['foreign_table']);
149 // nasty hack, but it's currently not possible to just quote anything "inside" the value but not escaping
150 // the whole field as it is not known where it is used in the WHERE clause
151 $replacement['###PAGE_TSCONFIG_STR###'] = trim($connection->quote($fieldTSconfig['PAGE_TSCONFIG_STR']), '\'');
152 }
153 }
154 $config['addWhere'] = strtr(' ' . $config['addWhere'], $replacement);
155 }
156
157 // instantiate the class that should fetch the records for this $queryTable
158 $receiverClassName = $config['receiverClass'];
159 if (!class_exists($receiverClassName)) {
160 $receiverClassName = SuggestWizardDefaultReceiver::class;
161 }
162 $receiverObj = GeneralUtility::makeInstance($receiverClassName, $queryTable, $config);
163 $params = ['value' => $search];
164 $rows = $receiverObj->queryTable($params);
165 if (empty($rows)) {
166 continue;
167 }
168 $resultRows = $rows + $resultRows;
169 unset($rows);
170 }
171
172 // Limit the number of items in the result list
173 $maxItems = isset($config['maxItemsInResultList']) ? $config['maxItemsInResultList'] : 10;
174 $maxItems = min(count($resultRows), $maxItems);
175
176 array_splice($resultRows, $maxItems);
177
178 $response->getBody()->write(json_encode(array_values($resultRows)));
179 return $response;
180 }
181
182 /**
183 * Returns TRUE if a table has been marked as hidden in the configuration
184 *
185 * @param array $tableConfig
186 * @return bool
187 */
188 protected function isTableHidden(array $tableConfig)
189 {
190 return (bool)$tableConfig['ctrl']['hideTable'];
191 }
192
193 /**
194 * Checks if the current backend user is allowed to access the given table, based on the ctrl-section of the
195 * table's configuration array (TCA) entry.
196 *
197 * @param array $tableConfig
198 * @return bool
199 */
200 protected function currentBackendUserMayAccessTable(array $tableConfig)
201 {
202 if ($this->getBackendUser()->isAdmin()) {
203 return true;
204 }
205
206 // If the user is no admin, they may not access admin-only tables
207 if ($tableConfig['ctrl']['adminOnly']) {
208 return false;
209 }
210
211 // allow access to root level pages if security restrictions should be bypassed
212 return !$tableConfig['ctrl']['rootLevel'] || $tableConfig['ctrl']['security']['ignoreRootLevelRestriction'];
213 }
214
215 /**
216 * Get 'config' section of field from resolved data structure specified by flex form path in $parts
217 *
218 * @param array $parts
219 * @param array $dataStructure
220 * @return array
221 */
222 protected function getFlexFieldConfiguration(array $parts, array $dataStructure)
223 {
224 if (count($parts) === 6) {
225 // Search a flex field, example:
226 // flex_1|data|sDb|lDEF|group_db_1|vDEF
227 if (!isset($dataStructure['sheets'][$parts[2]]['ROOT']['el'][$parts[4]]['TCEforms']['config'])) {
228 throw new \RuntimeException(
229 'Specified path ' . implode('|', $parts) . ' not found in flex form data structure',
230 1480609491
231 );
232 }
233 $fieldConfig = $dataStructure['sheets'][$parts[2]]['ROOT']['el'][$parts[4]]['TCEforms']['config'];
234 } elseif (count($parts) === 11) {
235 // Search a flex field in a section container, example:
236 // flex_1|data|sSuggestCheckCombination|lDEF|settings.subelements|el|1|item|el|content|vDEF
237 if (!isset($dataStructure['sheets'][$parts[2]]['ROOT']['el'][$parts[4]]['el'][$parts[7]]['el'][$parts[9]]['TCEforms']['config'])) {
238 throw new \RuntimeException(
239 'Specified path ' . implode('|', $parts) . ' not found in flex form section container data structure',
240 1480611208
241 );
242 }
243 $fieldConfig = $dataStructure['sheets'][$parts[2]]['ROOT']['el'][$parts[4]]['el'][$parts[7]]['el'][$parts[9]]['TCEforms']['config'];
244 } else {
245 throw new \RuntimeException(
246 'Invalid flex form path ' . implode('|', $parts),
247 1480611252
248 );
249 }
250 return $fieldConfig;
251 }
252
253 /**
254 * Returns the configuration for the suggest wizard for the given table. This does multiple overlays from the
255 * TSconfig.
256 *
257 * @param string $queryTable The table to query
258 * @param array $wizardConfig The configuration for the wizard as configured in the data structure
259 * @param array $TSconfig The TSconfig array of the current page
260 * @param string $table The table where the wizard is used
261 * @param string $field The field where the wizard is used
262 * @return array
263 */
264 protected function getConfigurationForTable($queryTable, array $wizardConfig, array $TSconfig, $table, $field)
265 {
266 $config = (array)$wizardConfig['default'];
267
268 if (is_array($wizardConfig[$queryTable])) {
269 ArrayUtility::mergeRecursiveWithOverrule($config, $wizardConfig[$queryTable]);
270 }
271 $globalSuggestTsConfig = $TSconfig['TCEFORM.']['suggest.'];
272 $currentFieldSuggestTsConfig = $TSconfig['TCEFORM.'][$table . '.'][$field . '.']['suggest.'];
273
274 // merge the configurations of different "levels" to get the working configuration for this table and
275 // field (i.e., go from the most general to the most special configuration)
276 if (is_array($globalSuggestTsConfig['default.'])) {
277 ArrayUtility::mergeRecursiveWithOverrule($config, $globalSuggestTsConfig['default.']);
278 }
279
280 if (is_array($globalSuggestTsConfig[$queryTable . '.'])) {
281 ArrayUtility::mergeRecursiveWithOverrule($config, $globalSuggestTsConfig[$queryTable . '.']);
282 }
283
284 // use $table instead of $queryTable here because we overlay a config
285 // for the input-field here, not for the queried table
286 if (is_array($currentFieldSuggestTsConfig['default.'])) {
287 ArrayUtility::mergeRecursiveWithOverrule($config, $currentFieldSuggestTsConfig['default.']);
288 }
289
290 if (is_array($currentFieldSuggestTsConfig[$queryTable . '.'])) {
291 ArrayUtility::mergeRecursiveWithOverrule($config, $currentFieldSuggestTsConfig[$queryTable . '.']);
292 }
293
294 return $config;
295 }
296
297 /**
298 * Checks the given field configuration for the tables that should be used for querying and returns them as an
299 * array.
300 *
301 * @param array $fieldConfig
302 * @return array
303 */
304 protected function getTablesToQueryFromFieldConfiguration(array $fieldConfig)
305 {
306 $queryTables = [];
307
308 if (isset($fieldConfig['allowed'])) {
309 if ($fieldConfig['allowed'] !== '*') {
310 // list of allowed tables
311 $queryTables = GeneralUtility::trimExplode(',', $fieldConfig['allowed']);
312 } else {
313 // all tables are allowed, if the user can access them
314 foreach ($GLOBALS['TCA'] as $tableName => $tableConfig) {
315 if (!$this->isTableHidden($tableConfig) && $this->currentBackendUserMayAccessTable($tableConfig)) {
316 $queryTables[] = $tableName;
317 }
318 }
319 unset($tableName, $tableConfig);
320 }
321 } elseif (isset($fieldConfig['foreign_table'])) {
322 // use the foreign table
323 $queryTables = [$fieldConfig['foreign_table']];
324 }
325
326 return $queryTables;
327 }
328
329 /**
330 * Returns the SQL WHERE clause to use for querying records. This is currently only relevant if a foreign_table
331 * is configured and should be used; it could e.g. be used to limit to a certain subset of records from the
332 * foreign table
333 *
334 * @param array $fieldConfig
335 * @return string
336 */
337 protected function getWhereClause(array $fieldConfig)
338 {
339 if (!isset($fieldConfig['foreign_table'])) {
340 return '';
341 }
342
343 // strip ORDER BY clause
344 return trim(preg_replace('/ORDER[[:space:]]+BY.*/i', '', $fieldConfig['foreign_table_where']));
345 }
346
347 /**
348 * @return \TYPO3\CMS\Core\Authentication\BackendUserAuthentication
349 */
350 protected function getBackendUser()
351 {
352 return $GLOBALS['BE_USER'];
353 }
354 }