├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .stylelintrc ├── .yarn └── releases │ └── yarn-1.22.19.cjs ├── .yarnrc ├── Classes └── Controller │ └── ModuleController.php ├── Configuration ├── Policy.yaml ├── Settings.yaml └── Views.yaml ├── Documentation ├── edit-redirects.png └── filter-redirects.png ├── LICENSE ├── README.md ├── Resources ├── Private │ ├── FusionModule │ │ ├── Components │ │ │ ├── FlashMessages.fusion │ │ │ ├── ImportProtocol.fusion │ │ │ └── Redirect.List.fusion │ │ ├── Root.fusion │ │ ├── Routing.fusion │ │ └── Views │ │ │ ├── Export.fusion │ │ │ ├── Import.fusion │ │ │ ├── ImportCsv.fusion │ │ │ └── Index.fusion │ ├── JavaScript │ │ ├── components │ │ │ ├── Filters.tsx │ │ │ ├── Icon.tsx │ │ │ ├── RedirectForm.tsx │ │ │ ├── RedirectList.tsx │ │ │ ├── RedirectListItem.tsx │ │ │ ├── Tooltip.tsx │ │ │ └── index.ts │ │ ├── index.tsx │ │ ├── package.json │ │ ├── providers │ │ │ ├── Intl.tsx │ │ │ ├── RedirectProvider.tsx │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── typings │ │ │ └── global.d.ts │ │ └── util │ │ │ ├── datetime.ts │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ └── url.ts │ ├── Styles │ │ ├── _variables.scss │ │ ├── components │ │ │ ├── _add-redirect-form.scss │ │ │ ├── _export-redirects-form.scss │ │ │ ├── _filter.scss │ │ │ ├── _list.scss │ │ │ ├── _notifications.scss │ │ │ ├── _protocol.scss │ │ │ ├── _redirects-table.scss │ │ │ └── _tooltip.scss │ │ ├── styles.scss │ │ └── vendor │ │ │ └── _datepicker.scss │ └── Translations │ │ ├── de │ │ └── Modules.xlf │ │ └── en │ │ └── Modules.xlf └── Public │ └── Assets │ ├── main.bundle.css │ ├── main.bundle.css.map │ ├── main.bundle.js │ └── main.bundle.js.map ├── composer.json ├── package.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 4 8 | 9 | [*.tsx] 10 | indent_size = 4 11 | 12 | [*.{yml,yaml,json}] 13 | indent_size = 2 14 | 15 | [*.md] 16 | indent_size = 2 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react/recommended', 8 | 'plugin:prettier/recommended', 9 | ], 10 | plugins: ['prettier', 'react', 'react-hooks'], 11 | settings: { 12 | react: { 13 | version: 'detect', 14 | }, 15 | }, 16 | env: { 17 | browser: true, 18 | node: true, 19 | }, 20 | rules: { 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | '@typescript-eslint/no-var-requires': 'off', 25 | '@typescript-eslint/ban-ts-ignore': 'off', 26 | '@typescript-eslint/ban-ts-comment': 'off', 27 | 'react/prop-types': 'off', 28 | 'prettier/prettier': ['error'], 29 | 'react-hooks/rules-of-hooks': 'error', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | name: 'Neos CMS redirecthandler build' 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: '16' 18 | cache: 'yarn' 19 | 20 | - name: Install dependencies 21 | run: yarn 22 | 23 | - name: Build the plugin 24 | run: yarn lint 25 | 26 | - name: Build the plugin 27 | run: yarn build 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sass-cache 2 | node_modules/ 3 | /.cache 4 | /.parcel-cache 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 4 6 | } 7 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended-scss", 3 | "rules": { 4 | "no-descending-specificity": null, 5 | "no-invalid-position-at-import-rule": null 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | yarn-path ".yarn/releases/yarn-1.22.19.cjs" 2 | -------------------------------------------------------------------------------- /Classes/Controller/ModuleController.php: -------------------------------------------------------------------------------- 1 | FusionView::class, 67 | 'json' => JsonView::class, 68 | ]; 69 | 70 | /** 71 | * @Flow\Inject 72 | * @var SecurityContext 73 | */ 74 | protected $securityContext; 75 | 76 | /** 77 | * @Flow\Inject 78 | * @var RedirectStorageInterface 79 | */ 80 | protected $redirectStorage; 81 | 82 | /** 83 | * @Flow\Inject 84 | * @var PersistenceManagerInterface 85 | */ 86 | protected $persistenceManager; 87 | 88 | /** 89 | * @Flow\Inject 90 | * @var Translator 91 | */ 92 | protected $translator; 93 | 94 | /** 95 | * @Flow\Inject 96 | * @var LocalizationService 97 | */ 98 | protected $localizationService; 99 | 100 | /** 101 | * @Flow\Inject 102 | * @var RedirectExportService 103 | */ 104 | protected $redirectExportService; 105 | 106 | /** 107 | * @Flow\Inject 108 | * @var RedirectImportService 109 | */ 110 | protected $redirectImportService; 111 | 112 | /** 113 | * @Flow\Inject 114 | * @var ResourceManager 115 | */ 116 | protected $resourceManager; 117 | 118 | /** 119 | * @Flow\Inject 120 | * @var DomainRepository 121 | */ 122 | protected $domainRepository; 123 | 124 | /** 125 | * @Flow\InjectConfiguration(path="validation", package="Neos.RedirectHandler") 126 | * @var array 127 | */ 128 | protected $validationOptions; 129 | 130 | /** 131 | * Renders the list of all redirects and allows modifying them. 132 | */ 133 | public function indexAction(): void 134 | { 135 | $redirects = $this->redirectStorage->getAll(); 136 | $csrfToken = $this->securityContext->getCsrfProtectionToken(); 137 | $flashMessages = $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(); 138 | $currentLocale = $this->localizationService->getConfiguration()->getCurrentLocale(); 139 | $usedHostOptions = []; 140 | 141 | // Serialize redirects for the filterable list in the frontend 142 | // TODO: Provide the list via a json action to the frontend for async loading 143 | $redirectsJson = ''; 144 | /** @var RedirectInterface $redirect */ 145 | foreach ($redirects as $redirect) { 146 | $usedHostOptions[] = $redirect->getHost(); 147 | $redirectsJson .= json_encode($redirect) . ','; 148 | } 149 | $redirectsJson = '[' . trim($redirectsJson, ',') . ']'; 150 | 151 | $domainOptions = array_map(static fn(Domain $domain) => $domain->getHostname(), $this->domainRepository->findAll()->toArray()); 152 | 153 | $hostOptions = array_filter(array_unique(array_merge($domainOptions, $usedHostOptions))); 154 | sort($hostOptions); 155 | 156 | $this->view->assignMultiple([ 157 | 'redirectsJson' => $redirectsJson, 158 | 'hostOptions' => $hostOptions, 159 | 'flashMessages' => $flashMessages, 160 | 'csrfToken' => $csrfToken, 161 | 'locale' => $currentLocale, 162 | ]); 163 | } 164 | 165 | protected function processRedirectStartAndEndDate(?string $startDateTimeString = null, ?string $endDateTimeString = null): array 166 | { 167 | $valid = true; 168 | $startDateTime = null; 169 | $endDateTime = null; 170 | 171 | if ($startDateTimeString) { 172 | try { 173 | $startDateTime = new \DateTime($startDateTimeString); 174 | } catch (Exception $e) { 175 | $valid = false; 176 | $this->addFlashMessage('', $this->translateById('error.invalidStartDateTime'), Message::SEVERITY_ERROR); 177 | } 178 | } 179 | 180 | if ($endDateTimeString) { 181 | try { 182 | $endDateTime = new \DateTime($endDateTimeString); 183 | } catch (Exception $e) { 184 | $valid = false; 185 | $this->addFlashMessage('', $this->translateById('error.invalidEndDateTime'), Message::SEVERITY_ERROR); 186 | } 187 | } 188 | 189 | return [$startDateTime, $endDateTime, $valid]; 190 | } 191 | 192 | /** 193 | * Creates a single redirect and goes back to the list 194 | * 195 | * @throws StopActionException 196 | */ 197 | public function createAction(): void 198 | { 199 | [ 200 | 'host' => $host, 201 | 'sourceUriPath' => $sourceUriPath, 202 | 'targetUriPath' => $targetUriPath, 203 | 'statusCode' => $statusCode, 204 | 'startDateTime' => $startDateTime, 205 | 'endDateTime' => $endDateTime, 206 | 'comment' => $comment, 207 | ] = $this->request->getArguments(); 208 | 209 | $statusCode = (int)$statusCode; 210 | 211 | [$startDateTime, $endDateTime, $creationStatus] = $this->processRedirectStartAndEndDate($startDateTime, $endDateTime); 212 | 213 | if ($creationStatus) { 214 | $changedRedirects = $this->addRedirect( 215 | $sourceUriPath, $targetUriPath, $statusCode, $host, $comment, $startDateTime, $endDateTime 216 | ); 217 | $creationStatus = count($changedRedirects) > 0; 218 | } else { 219 | $changedRedirects = []; 220 | } 221 | 222 | if (!$creationStatus) { 223 | $message = $this->translateById('error.redirectNotCreated'); 224 | $this->addFlashMessage('', $message, Message::SEVERITY_ERROR); 225 | } else { 226 | // Build list of changed redirects for feedback to user 227 | $message = $this->createChangedRedirectList($changedRedirects); 228 | 229 | /** @var RedirectInterface $createdRedirect */ 230 | $createdRedirect = $changedRedirects[0]; 231 | 232 | $messageTitle = $this->translateById(count($changedRedirects) === 1 ? 'message.redirectCreated' : 'warning.redirectCreatedWithChanges', 233 | [ 234 | $createdRedirect->getHost(), 235 | $createdRedirect->getSourceUriPath(), 236 | $createdRedirect->getTargetUriPath(), 237 | $createdRedirect->getStatusCode() 238 | ]); 239 | 240 | $this->addFlashMessage($message, $messageTitle, 241 | count($changedRedirects) === 1 ? Message::SEVERITY_OK : Message::SEVERITY_WARNING); 242 | } 243 | 244 | if ($this->request->getFormat() === 'json') { 245 | $this->view->assign('value', [ 246 | 'success' => $creationStatus, 247 | 'changedRedirects' => $changedRedirects, 248 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 249 | ]); 250 | } else { 251 | $this->redirect('index'); 252 | } 253 | } 254 | 255 | /** 256 | * Updates a single redirect and goes back to the list 257 | * 258 | * @throws StopActionException 259 | */ 260 | public function updateAction(): void 261 | { 262 | [ 263 | 'host' => $host, 264 | 'originalHost' => $originalHost, 265 | 'sourceUriPath' => $sourceUriPath, 266 | 'originalSourceUriPath' => $originalSourceUriPath, 267 | 'targetUriPath' => $targetUriPath, 268 | 'statusCode' => $statusCode, 269 | 'startDateTime' => $startDateTime, 270 | 'endDateTime' => $endDateTime, 271 | 'comment' => $comment, 272 | ] = $this->request->getArguments(); 273 | 274 | $statusCode = (int)$statusCode; 275 | 276 | [$startDateTime, $endDateTime, $updateStatus] = $this->processRedirectStartAndEndDate($startDateTime, $endDateTime); 277 | 278 | if ($updateStatus) { 279 | $changedRedirects = $this->updateRedirect( 280 | $originalSourceUriPath, $originalHost, $sourceUriPath, $targetUriPath, $statusCode, $host, $comment, 281 | $startDateTime, $endDateTime 282 | ); 283 | $updateStatus = count($changedRedirects) > 0; 284 | } else { 285 | $changedRedirects = []; 286 | } 287 | 288 | if (!$updateStatus) { 289 | $message = $this->translateById('error.redirectNotUpdated'); 290 | $this->addFlashMessage('', $message, Message::SEVERITY_ERROR); 291 | } else { 292 | // Build list of changed redirects for feedback to user 293 | $message = $this->createChangedRedirectList($changedRedirects); 294 | 295 | /** @var RedirectInterface $createdRedirect */ 296 | $createdRedirect = $changedRedirects[0]; 297 | 298 | $messageTitle = $this->translateById( 299 | count($changedRedirects) === 1 ? 'message.redirectUpdated' : 'warning.redirectUpdatedWithChanges', 300 | [ 301 | $createdRedirect->getHost(), 302 | $createdRedirect->getSourceUriPath(), 303 | $createdRedirect->getTargetUriPath(), 304 | $createdRedirect->getStatusCode() 305 | ] 306 | ); 307 | 308 | $this->addFlashMessage($message, $messageTitle, 309 | count($changedRedirects) === 1 ? Message::SEVERITY_OK : Message::SEVERITY_WARNING); 310 | } 311 | 312 | if ($this->request->getFormat() === 'json') { 313 | $this->view->assign('value', [ 314 | 'success' => $updateStatus, 315 | 'changedRedirects' => $changedRedirects, 316 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 317 | ]); 318 | } else { 319 | $this->redirect('index'); 320 | } 321 | } 322 | 323 | /** 324 | * Deletes a single redirect and goes back to the list 325 | * 326 | * @throws StopActionException 327 | */ 328 | public function deleteAction(): void 329 | { 330 | [ 331 | 'host' => $host, 332 | 'sourceUriPath' => $sourceUriPath, 333 | ] = $this->request->getArguments(); 334 | 335 | $status = $this->deleteRedirect($sourceUriPath, $host ?? null); 336 | 337 | if ($status === false) { 338 | $message = $this->translateById('error.redirectNotDeleted'); 339 | $this->addFlashMessage('', $message, Message::SEVERITY_ERROR); 340 | } else { 341 | $message = $this->translateById('message.redirectDeleted', [$host, $sourceUriPath]); 342 | $this->addFlashMessage('', $message); 343 | } 344 | 345 | if ($this->request->getFormat() === 'json') { 346 | $this->view->assign('value', [ 347 | 'success' => $status, 348 | 'messages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), 349 | ]); 350 | } else { 351 | $this->redirect('index'); 352 | } 353 | } 354 | 355 | /** 356 | * Shows the import interface with its options, actions and a protocol after an action 357 | */ 358 | public function importAction(): void 359 | { 360 | $csrfToken = $this->securityContext->getCsrfProtectionToken(); 361 | $flashMessages = $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(); 362 | $this->view->assignMultiple([ 363 | 'csrfToken' => $csrfToken, 364 | 'flashMessages' => $flashMessages, 365 | ]); 366 | } 367 | 368 | /** 369 | * Shows the export interface with its options and actions 370 | */ 371 | public function exportAction(): void 372 | { 373 | $csrfToken = $this->securityContext->getCsrfProtectionToken(); 374 | $this->view->assignMultiple([ 375 | 'csrfToken' => $csrfToken, 376 | ]); 377 | } 378 | 379 | /** 380 | * Exports all redirects into a CSV file and starts its download 381 | * @throws CannotInsertRecord 382 | */ 383 | public function exportCsvAction(): void 384 | { 385 | $includeInactiveRedirects = $this->request->hasArgument('includeInactiveRedirects'); 386 | $includeGeneratedRedirects = $this->request->hasArgument('includeGeneratedRedirects'); 387 | 388 | // TODO: Make host selectable from distinct list of existing hosts 389 | $host = null; 390 | 391 | $csvWriter = $this->redirectExportService->exportCsv( 392 | $host, 393 | !$includeInactiveRedirects, 394 | $includeGeneratedRedirects ? null : RedirectInterface::REDIRECT_TYPE_MANUAL 395 | ); 396 | $filename = 'neos-redirects-' . (new DateTime())->format('Y-m-d-H-i-s') . '.csv'; 397 | 398 | $content = $csvWriter->getContent(); 399 | header('Pragma: no-cache'); 400 | header('Content-type: application/text'); 401 | header('Content-Length: ' . strlen($content)); 402 | header('Content-Disposition: attachment; filename=' . $filename); 403 | header('Content-Transfer-Encoding: binary'); 404 | header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); 405 | 406 | echo $content; 407 | 408 | exit; 409 | } 410 | 411 | /** 412 | * Tries to import redirects from the given CSV file and then shows a protocol 413 | * 414 | * @throws StopActionException 415 | */ 416 | public function importCsvAction(?PersistentResource $csvFile = null, string $delimiter = ','): void 417 | { 418 | $protocol = []; 419 | 420 | if (!$csvFile) { 421 | $this->addFlashMessage($this->translateById('error.csvFileNotSet'), '', Message::SEVERITY_ERROR); 422 | $this->redirect('import'); 423 | } 424 | 425 | try { 426 | // Use temporary local copy as stream doesn't work reliably with cloud based storage 427 | $reader = Reader::createFromPath($csvFile->createTemporaryLocalCopy()); 428 | $reader->setDelimiter($delimiter); 429 | 430 | $protocol = $this->redirectImportService->import($reader->getIterator()); 431 | $protocolErrors = array_filter($protocol, static function ($entry) { 432 | return $entry['type'] === RedirectImportService::REDIRECT_IMPORT_MESSAGE_TYPE_ERROR; 433 | }); 434 | 435 | try { 436 | $this->resourceManager->deleteResource($csvFile); 437 | } catch (Exception $e) { 438 | $this->logger->warning('Could not delete csv file after importing redirects', [$e->getMessage()]); 439 | } 440 | 441 | if (count($protocol) === 0) { 442 | $this->addFlashMessage($this->translateById('error.importCsvEmpty')); 443 | } elseif (count($protocolErrors) > 0) { 444 | $this->addFlashMessage($this->translateById('message.importCsvSuccessWithErrors'), '', Message::SEVERITY_WARNING); 445 | } else { 446 | $this->addFlashMessage($this->translateById('message.importCsvSuccess')); 447 | } 448 | } catch (CsvException $e) { 449 | $this->addFlashMessage($this->translateById('error.importCsvFailed'), '', Message::SEVERITY_ERROR); 450 | $this->redirect('import'); 451 | } catch (ResourceException $e) { 452 | $this->addFlashMessage($this->translateById('error.importResourceFailed'), '', Message::SEVERITY_ERROR); 453 | $this->redirect('import'); 454 | } 455 | 456 | $flashMessages = $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(); 457 | $this->view->assignMultiple([ 458 | 'protocol' => $protocol, 459 | 'flashMessages' => $flashMessages, 460 | ]); 461 | } 462 | 463 | protected function addRedirect( 464 | string $sourceUriPath, 465 | string $targetUriPath, 466 | int $statusCode, 467 | ?string $host = null, 468 | ?string $comment = null, 469 | ?DateTime $startDateTime = null, 470 | ?DateTime $endDateTime = null, 471 | bool $force = false 472 | ): array 473 | { 474 | $sourceUriPath = trim($sourceUriPath); 475 | $targetUriPath = trim($targetUriPath); 476 | 477 | if (!$this->validateRedirectAttributes($host, $sourceUriPath, $targetUriPath)) { 478 | return []; 479 | } 480 | 481 | $redirect = $this->redirectStorage->getOneBySourceUriPathAndHost($sourceUriPath, $host ?: null, false); 482 | $isSame = $this->isSame($sourceUriPath, $targetUriPath, $host, $statusCode, $redirect); 483 | $go = true; 484 | 485 | if ($redirect !== null && $isSame === false && $force === false) { 486 | $go = false; // Ignore.. A redirect with the same source URI exist. 487 | } elseif ($redirect !== null && $isSame === false && $force === true) { 488 | $this->redirectStorage->removeOneBySourceUriPathAndHost($sourceUriPath, $host); 489 | $this->persistenceManager->persistAll(); 490 | } elseif ($redirect !== null && $isSame === true) { 491 | $go = false; // Ignore.. Not valid. 492 | } 493 | 494 | if ($go) { 495 | $creator = $this->securityContext->getAccount()->getAccountIdentifier(); 496 | 497 | $redirects = $this->redirectStorage->addRedirect($sourceUriPath, $targetUriPath, $statusCode, [$host], 498 | $creator, 499 | $comment, RedirectInterface::REDIRECT_TYPE_MANUAL, $startDateTime, $endDateTime); 500 | 501 | $this->persistenceManager->persistAll(); 502 | return $redirects; 503 | } 504 | 505 | return []; 506 | } 507 | 508 | /** 509 | * @param string $originalSourceUriPath 510 | * @param string|null $originalHost 511 | * @param string $sourceUriPath 512 | * @param string|null $targetUriPath 513 | * @param integer $statusCode 514 | * @param string|null $host 515 | * @param string|null $comment 516 | * @param DateTime|null $startDateTime 517 | * @param DateTime|null $endDateTime 518 | * @param bool $force 519 | * @return array 520 | */ 521 | protected function updateRedirect( 522 | string $originalSourceUriPath, 523 | ?string $originalHost, 524 | string $sourceUriPath, 525 | string $targetUriPath, 526 | int $statusCode, 527 | ?string $host = null, 528 | ?string $comment = null, 529 | ?DateTime $startDateTime = null, 530 | ?DateTime $endDateTime = null, 531 | bool $force = false 532 | ): array 533 | { 534 | $sourceUriPath = trim($sourceUriPath); 535 | $targetUriPath = trim($targetUriPath); 536 | 537 | if (!$this->validateRedirectAttributes($host, $sourceUriPath, $targetUriPath)) { 538 | $this->addFlashMessage($this->translateById('error.redirectNotValid'), '', Message::SEVERITY_ERROR); 539 | return []; 540 | } 541 | 542 | // Check for existing redirect with the same properties before changing the edited redirect 543 | if ($originalSourceUriPath !== $sourceUriPath || $originalHost !== $host) { 544 | $existingRedirect = $this->redirectStorage->getOneBySourceUriPathAndHost($sourceUriPath, 545 | $host ?: null, false); 546 | if ($existingRedirect !== null) { 547 | $this->addFlashMessage($this->translateById('error.redirectExists'), '', Message::SEVERITY_ERROR); 548 | return []; 549 | } 550 | } 551 | 552 | $go = false; 553 | $redirect = $this->redirectStorage->getOneBySourceUriPathAndHost($originalSourceUriPath, 554 | $originalHost ?: null, false); 555 | 556 | if ($redirect !== null && $force === false) { 557 | $this->deleteRedirect($originalSourceUriPath, $originalHost); 558 | $go = true; 559 | } elseif ($force === true) { 560 | $go = true; 561 | } 562 | 563 | if ($go) { 564 | return $this->addRedirect($sourceUriPath, $targetUriPath, $statusCode, $host, $comment, $startDateTime, 565 | $endDateTime, $force); 566 | } 567 | 568 | return []; 569 | } 570 | 571 | protected function deleteRedirect(string $sourceUriPath, ?string $host = null): bool 572 | { 573 | $redirect = $this->redirectStorage->getOneBySourceUriPathAndHost($sourceUriPath, $host ?: null); 574 | if ($redirect === null) { 575 | return false; 576 | } 577 | $this->redirectStorage->removeOneBySourceUriPathAndHost($sourceUriPath, $host); 578 | $this->persistenceManager->persistAll(); 579 | 580 | return true; 581 | } 582 | 583 | protected function validateRedirectAttributes(?string $host, string $sourceUriPath, string $targetUriPath): bool 584 | { 585 | if ($sourceUriPath === $targetUriPath) { 586 | $this->addFlashMessage('', $this->translateById('error.sameSourceAndTarget'), 587 | Message::SEVERITY_WARNING); 588 | } elseif (!preg_match($this->validationOptions['sourceUriPath'], $sourceUriPath)) { 589 | $this->addFlashMessage('', 590 | $this->translateById('error.sourceUriPathNotValid', [$this->validationOptions['sourceUriPath']]), 591 | Message::SEVERITY_WARNING); 592 | } else { 593 | return true; 594 | } 595 | return false; 596 | } 597 | 598 | protected function isSame( 599 | string $sourceUriPath, 600 | string $targetUriPath, 601 | ?string $host, 602 | int $statusCode, 603 | ?RedirectInterface $redirect = null 604 | ): bool 605 | { 606 | if ($redirect === null) { 607 | return false; 608 | } 609 | 610 | return $redirect->getSourceUriPath() === $sourceUriPath 611 | && $redirect->getTargetUriPath() === $targetUriPath 612 | && $redirect->getHost() === $host 613 | && $redirect->getStatusCode() === $statusCode; 614 | } 615 | 616 | /** 617 | * Shorthand to translate labels for this package 618 | */ 619 | protected function translateById(string $id, array $arguments = []): ?string 620 | { 621 | try { 622 | return $this->translator->translateById($id, $arguments, null, null, 'Modules', 'Neos.RedirectHandler.Ui'); 623 | } catch (\Exception $e) { 624 | return $id; 625 | } 626 | } 627 | 628 | /** 629 | * Creates a html list of changed redirects 630 | * 631 | * @param array $changedRedirects 632 | */ 633 | protected function createChangedRedirectList(array $changedRedirects): string 634 | { 635 | $list = array_reduce($changedRedirects, static function ($carry, RedirectInterface $redirect) { 636 | return $carry . '
  • ' . $redirect->getHost() . '/' . $redirect->getSourceUriPath() . ' → /' . $redirect->getTargetUriPath() . '
  • '; 637 | }, ''); 638 | return $list ? '

    ' . $this->translateById('message.relatedChanges') . '

    ' : ''; 639 | } 640 | } 641 | -------------------------------------------------------------------------------- /Configuration/Policy.yaml: -------------------------------------------------------------------------------- 1 | privilegeTargets: 2 | 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': 3 | 'Neos.RedirectHandler.Ui:Module': 4 | matcher: 'method(Neos\RedirectHandler\Ui\Controller\ModuleController->(.*)Action())' 5 | 6 | 'Neos\Neos\Security\Authorization\Privilege\ModulePrivilege': 7 | 'Neos.RedirectHandler.Ui:Backend.Module.Management.Redirects': 8 | matcher: 'management/redirects' 9 | 10 | roles: 11 | 'Neos.Neos:Administrator': 12 | privileges: 13 | - privilegeTarget: 'Neos.RedirectHandler.Ui:Backend.Module.Management.Redirects' 14 | permission: GRANT 15 | - privilegeTarget: 'Neos.RedirectHandler.Ui:Module' 16 | permission: GRANT 17 | 18 | 'Neos.RedirectHandler.Ui:RedirectAdministrator': 19 | label: 'Redirect Administrator' 20 | description: 'Allows managing redirects' 21 | privileges: 22 | - privilegeTarget: 'Neos.RedirectHandler.Ui:Backend.Module.Management.Redirects' 23 | permission: GRANT 24 | - privilegeTarget: 'Neos.RedirectHandler.Ui:Module' 25 | permission: GRANT 26 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | RedirectHandler: 3 | statusCode: 4 | redirect: 301 5 | gone: 410 6 | 7 | Ui: 8 | # The preselected status code for the dropdown when creating new redirects 9 | defaultStatusCode: 307 10 | # Only show redirects with the given redirects when the module is opened 11 | initialStatusCodeFilter: -1 12 | initialTypeFilter: 'manual' 13 | validation: 14 | # This is the pattern for matching the input in the HTML forms 15 | sourceUriPath: '^[a-zA-Z0-9_\-\/\.%]+$' 16 | csv: 17 | delimiterOptions: [';', ',', '|'] 18 | statusCodes: 19 | 301: i18n 20 | 302: i18n 21 | 303: i18n 22 | 307: i18n 23 | 403: i18n 24 | 404: i18n 25 | 410: i18n 26 | 451: i18n 27 | 28 | Neos: 29 | userInterface: 30 | translation: 31 | autoInclude: 32 | 'Neos.RedirectHandler.Ui': ['Modules'] 33 | modules: 34 | management: 35 | submodules: 36 | redirects: 37 | label: 'Neos.RedirectHandler.Ui:Modules:module.label' 38 | controller: '\Neos\RedirectHandler\Ui\Controller\ModuleController' 39 | description: 'Neos.RedirectHandler.Ui:Modules:module.description' 40 | icon: 'fas fa-share' 41 | resource: 'Neos.RedirectHandler.Ui:Backend.Module' 42 | privilegeTarget: 'Neos.RedirectHandler.Ui:Module' 43 | mainStylesheet: 'Lite' 44 | additionalResources: 45 | styleSheets: 46 | main: 'resource://Neos.RedirectHandler.Ui/Public/Assets/main.bundle.css' 47 | javaScripts: 48 | main: 'resource://Neos.RedirectHandler.Ui/Public/Assets/main.bundle.js' 49 | -------------------------------------------------------------------------------- /Configuration/Views.yaml: -------------------------------------------------------------------------------- 1 | - 2 | requestFilter: 'isPackage("Neos.RedirectHandler.Ui") && isController("Module") && isFormat("html")' 3 | viewObjectName: 'Neos\Fusion\View\FusionView' 4 | options: 5 | fusionPathPatterns: 6 | - 'resource://Neos.Fusion/Private/Fusion' 7 | - 'resource://Neos.RedirectHandler.Ui/Private/FusionModule' 8 | -------------------------------------------------------------------------------- /Documentation/edit-redirects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neos/redirecthandler-ui/3f474949cb3685bb3e80740bb6c19b61730cd0ae/Documentation/edit-redirects.png -------------------------------------------------------------------------------- /Documentation/filter-redirects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neos/redirecthandler-ui/3f474949cb3685bb3e80740bb6c19b61730cd0ae/Documentation/filter-redirects.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neos.RedirectHandler.Ui 2 | [![Latest Stable Version](https://poser.pugx.org/neos/redirecthandler-ui/v/stable)](https://packagist.org/packages/neos/redirecthandler-ui) 3 | [![License](https://poser.pugx.org/neos/redirecthandler-ui/license)](https://packagist.org/packages/neos/redirecthandler-ui) 4 | 5 | This package provides a backend module to manage [Neos.RedirectHandler](https://github.com/neos/redirecthandler) redirects which are stored in [Neos.RedirectHandler.DatabaseStorage](https://github.com/neos/redirecthandler-databasestorage). 6 | 7 | ## Compatibility and Maintenance 8 | 9 | This package is compatible with Neos 4.3, 5.x, 7.x and will be maintained for upcoming versions. 10 | 11 | ## Installation 12 | 13 | 1. Run the following command in your site package: 14 | 15 | `composer require neos/redirecthandler-ui --no-update` 16 | 17 | 2. If you don't have dependencies for the other redirect packages yet you should also run the following command: 18 | 19 | `composer require neos/redirecthandler-neosadapter neos/redirecthandler-databasestorage --no-update` 20 | 21 | 3. Run `composer update` in your projects root folder. 22 | 23 | 4. Then you can add the `RedirectAdministrator` role to the users who need access to the new backend module. 24 | 25 | ## Screenshots 26 | 27 | Listing and editing redirects: 28 | 29 | ![Redirects Module Screenshot](Documentation/edit-redirects.png "Redirects Module Screenshot") 30 | 31 | Search & filter redirects: 32 | 33 | ![Filtering redirects](Documentation/filter-redirects.png "Redirects Module Screenshot with active filter") 34 | 35 | ## Documentation 36 | 37 | This package belongs to a group of packages which provides redirect functionality to the Flow Framework and to Neos. 38 | Therefore you can find the documentation regarding Neos [here](https://neos-redirecthandler-adapter.readthedocs.io/en/latest/). 39 | 40 | ## Contributing 41 | 42 | Please create issues on [Github](https://github.com/neos/redirecthandler-ui) if you encounter bugs or other issues. 43 | 44 | ### Working on the code 45 | 46 | The basis of the backend module is built with Fusion and the UI for managing the redirects 47 | is built with *React* and *Typescript*. 48 | 49 | #### Recompiling the js and css parts 50 | 51 | 1. Use *nvm* so you have the correct *npm* version. 52 | 2. Run `yarn` in the package folder. 53 | 3. Run `yarn watch` during development or `yarn build` for a new release. 54 | 55 | ## License 56 | 57 | See the [License](LICENSE.txt). 58 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Components/FlashMessages.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Component.FlashMessages) < prototype(Neos.Fusion:Component) { 2 | flashMessages = ${[]} 3 | 4 | renderer = afx` 5 |
    6 | 7 | 8 | 9 |
    10 | ` 11 | } 12 | 13 | prototype(Neos.RedirectHandler.Ui:Component.FlashMessages.Message) < prototype(Neos.Fusion:Component) { 14 | message = ${{}} 15 | 16 | severity = ${String.toLowerCase(this.message.severity)} 17 | severity.@process.replaceOKStatus = ${value == 'ok' ? 'success' : value} 18 | severity.@process.replaceNoticeStatus = ${value == 'notice' ? 'info' : value} 19 | 20 | renderer = afx` 21 |
    22 |
    23 | 24 |
    25 | {props.message.title || props.message.message} 26 |
    27 |
    {props.message.message}
    28 |
    29 |
    30 | ` 31 | } 32 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Components/ImportProtocol.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Component.ImportProtocol) < prototype(Neos.Fusion:Component) { 2 | protocol = ${[]} 3 | className = 'redirects-protocol' 4 | 5 | renderer = afx` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 22 | 28 | 33 | 38 | 43 | 44 | 45 |
    StatusRedirect{I18n.translate('Neos.RedirectHandler.Ui:Modules:startDateTime')}{I18n.translate('Neos.RedirectHandler.Ui:Modules:endDateTime')}{I18n.translate('Neos.RedirectHandler.Ui:Modules:comment')}
    17 | 18 | 19 | 20 | 21 | 23 | {String.htmlSpecialChars(entry.message)} 24 | 25 | {String.htmlSpecialChars(entry.redirect.host)}/{String.htmlSpecialChars(entry.redirect.sourceUriPath)} → {String.htmlSpecialChars(entry.redirect.targetUriPath)} ({String.htmlSpecialChars(entry.redirect.statusCode)}) 26 | 27 | 29 | 30 | {Date.format(entry.redirect.startDateTime, 'd.m.Y H:i')} 31 | 32 | 34 | 35 | {Date.format(entry.redirect.endDateTime, 'd.m.Y H:i')} 36 | 37 | 39 | 40 | {String.crop(String.htmlSpecialChars(entry.redirect.comment), 25, '…') || '–'} 41 | 42 |
    46 | ` 47 | } 48 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Components/Redirect.List.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Component.Redirect.List) < prototype(Neos.Fusion:Component) { 2 | redirectsJson = '[]' 3 | showHitCount = ${Configuration.setting('Neos.RedirectHandler.features.hitCounter')} 4 | csrfToken = null 5 | defaultStatusCode = ${Configuration.setting('Neos.RedirectHandler.Ui.defaultStatusCode')} 6 | initialStatusCodeFilter = ${Configuration.setting('Neos.RedirectHandler.Ui.initialStatusCodeFilter')} 7 | initialTypeFilter = ${Configuration.setting('Neos.RedirectHandler.Ui.initialTypeFilter')} 8 | validSourceUriPathPattern = ${Configuration.setting('Neos.RedirectHandler.Ui.validation.sourceUriPath')} 9 | // Don't render disabled status codes 10 | statusCodes = ${Array.filter(Configuration.setting('Neos.RedirectHandler.Ui.statusCodes'), x => !!x)} 11 | hostOptions = ${hostOptions} 12 | 13 | actions = Neos.Fusion:DataStructure { 14 | delete = Neos.Fusion:UriBuilder { 15 | action = 'delete' 16 | format = 'json' 17 | } 18 | update = Neos.Fusion:UriBuilder { 19 | action = 'update' 20 | format = 'json' 21 | } 22 | create = Neos.Fusion:UriBuilder { 23 | action = 'create' 24 | format = 'json' 25 | } 26 | } 27 | 28 | renderer = afx` 29 |
    39 |

    {I18n.translate('Neos.RedirectHandler.Ui:Modules:list.loading')}

    40 | 41 | 44 |
    45 | ` 46 | } 47 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Root.fusion: -------------------------------------------------------------------------------- 1 | include: resource://Neos.Fusion/Private/Fusion/Root.fusion 2 | include: resource://Neos.RedirectHandler.Ui/Private/FusionModule/**/*.fusion 3 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Routing.fusion: -------------------------------------------------------------------------------- 1 | Neos.RedirectHandler.Ui.ModuleController { 2 | index = afx` 3 | 4 | 5 | ` 6 | 7 | import = afx` 8 | 9 | 10 | ` 11 | 12 | importCsv = afx` 13 | 14 | 15 | ` 16 | 17 | export = afx` 18 | 19 | 20 | ` 21 | } 22 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Views/Export.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Module.Export) < prototype(Neos.Fusion:Component) { 2 | csrfToken = '' 3 | 4 | indexAction = Neos.Fusion:UriBuilder { 5 | action = 'index' 6 | } 7 | exportCsvAction = Neos.Fusion:UriBuilder { 8 | action = 'exportCsv' 9 | } 10 | importAction = Neos.Fusion:UriBuilder { 11 | action = 'import' 12 | } 13 | 14 | renderer = afx` 15 |
    16 |
    17 |
    18 | 19 |
    20 | 25 |
    26 |
    27 | 32 |
    33 |
    34 | 37 |
    38 |
    39 |
    40 | 41 | 49 |
    50 | ` 51 | } 52 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Views/Import.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Module.Import) < prototype(Neos.Fusion:Component) { 2 | csrfToken = '' 3 | delimiterOptions = ${Configuration.setting('Neos.RedirectHandler.Ui.csv.delimiterOptions')} 4 | importDocumentationUrl = 'https://neos-redirecthandler-adapter.readthedocs.io/en/latest/' 5 | 6 | indexAction = Neos.Fusion:UriBuilder { 7 | action = 'index' 8 | } 9 | importCsvAction = Neos.Fusion:UriBuilder { 10 | action = 'importCsv' 11 | } 12 | exportAction = Neos.Fusion:UriBuilder { 13 | action = 'export' 14 | } 15 | 16 | renderer = afx` 17 |
    18 |
    19 |

    {I18n.translate('Neos.RedirectHandler.Ui:Modules:header.csvFormHeader')}

    20 |
    21 |
    22 | 23 |
    24 |
    25 | 26 |
    27 |
    28 | 29 | 34 |
    35 |
    36 |

    37 | {I18n.translate('Neos.RedirectHandler.Ui:Modules:hint.importCsvFile')} 38 | {I18n.translate('Neos.RedirectHandler.Ui:Modules:hint.importGetHelp')} 39 |

    40 |
    41 | 44 |
    45 |
    46 |
    47 |
    48 | 49 | 57 |
    58 | ` 59 | } 60 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Views/ImportCsv.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Module.ImportCsv) < prototype(Neos.Fusion:Component) { 2 | protocol = ${[]} 3 | 4 | indexAction = Neos.Fusion:UriBuilder { 5 | action = 'index' 6 | } 7 | importAction = Neos.Fusion:UriBuilder { 8 | action = 'import' 9 | } 10 | 11 | renderer = afx` 12 |
    13 |
    14 |

    {I18n.translate('Neos.RedirectHandler.Ui:Modules:header.importProtocol')}

    15 |
    16 | 17 |
    18 | 19 | 27 |
    28 | ` 29 | } 30 | -------------------------------------------------------------------------------- /Resources/Private/FusionModule/Views/Index.fusion: -------------------------------------------------------------------------------- 1 | prototype(Neos.RedirectHandler.Ui:Module.Index) < prototype(Neos.Fusion:Component) { 2 | redirectsJson = '[]' 3 | hostOptions = '[]' 4 | csrfToken = '' 5 | 6 | importAction = Neos.Fusion:UriBuilder { 7 | action = 'import' 8 | } 9 | exportAction = Neos.Fusion:UriBuilder { 10 | action = 'export' 11 | } 12 | 13 | renderer = afx` 14 | 31 | ` 32 | } 33 | -------------------------------------------------------------------------------- /Resources/Private/JavaScript/components/Filters.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useIntl } from '../providers'; 4 | 5 | type FiltersProps = { 6 | handleUpdateSearch: (searchWord: string) => void; 7 | currentPage: number; 8 | filterStatusCode: number; 9 | filterType: string; 10 | filteredRedirects: Redirect[]; 11 | redirectCountByStatusCode: number[]; 12 | redirectCountByType: { [index: string]: number }; 13 | pagingParameters: number[]; 14 | showDetails: boolean; 15 | hasMorePages: boolean; 16 | handlePagination: (action: Pagination) => void; 17 | handleUpdateFilterStatusCode: (statusCode: number) => void; 18 | handleUpdateFilterType: (filterType: string) => void; 19 | handleToggleDetails: () => void; 20 | }; 21 | 22 | export enum Pagination { 23 | Left, 24 | Right, 25 | Start, 26 | End, 27 | } 28 | 29 | export default function Filters({ 30 | handleUpdateSearch, 31 | handleUpdateFilterStatusCode, 32 | handleUpdateFilterType, 33 | handlePagination, 34 | handleToggleDetails, 35 | showDetails, 36 | currentPage, 37 | filterStatusCode, 38 | filterType, 39 | filteredRedirects, 40 | redirectCountByStatusCode, 41 | redirectCountByType, 42 | pagingParameters, 43 | hasMorePages, 44 | }: FiltersProps) { 45 | const { translate } = useIntl(); 46 | 47 | return ( 48 |
    49 |
    50 |
    51 | 52 | handleUpdateSearch(e.target.value)} 57 | /> 58 |
    59 | 60 |
    61 | 62 | 78 |
    79 | 80 |
    81 | 82 | 98 |
    99 | 100 |
    101 |
    102 | {filteredRedirects.length > 0 && ( 103 | 111 | )} 112 | 113 | {filteredRedirects.length > 0 114 | ? translate('pagination.position', 'Showing {0}-{1} of {2}', pagingParameters) 115 | : translate('pagination.noResults', 'No redirects match your search')} 116 | 117 | {filteredRedirects.length > 0 && ( 118 | 126 | )} 127 |
    128 |
    129 | 130 |
    131 |
    143 |
    144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /Resources/Private/JavaScript/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type IconProps = { 4 | icon: string; 5 | }; 6 | 7 | export default function Icon({ icon }: IconProps) { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /Resources/Private/JavaScript/components/RedirectForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, PureComponent } from 'react'; 2 | import DatePicker from 'react-datepicker'; 3 | 4 | import { DateTimeUtil, UrlUtil, Helpers } from '../util'; 5 | import { RedirectContext } from '../providers'; 6 | import { Tooltip } from './index'; 7 | 8 | const MAX_INPUT_LENGTH = 500; 9 | 10 | type RedirectFormProps = { 11 | translate: (id: string, label: string, args?: any[]) => string; 12 | notificationHelper: NeosNotification; 13 | actions: { 14 | create: string; 15 | update: string; 16 | }; 17 | redirect: Redirect; 18 | idPrefix: string; 19 | validSourceUriPathPattern: string; 20 | handleNewRedirect: (changedRedirects: Redirect[]) => void; 21 | handleUpdatedRedirect: (changedRedirects: Redirect[], oldRedirect: Redirect) => void; 22 | handleCancelAction: () => void; 23 | }; 24 | 25 | type RedirectFormState = { 26 | [index: string]: any; 27 | 28 | host: string; 29 | sourceUriPath: string; 30 | targetUriPath: string; 31 | statusCode: number; 32 | startDateTime: string; 33 | endDateTime: string; 34 | comment: string; 35 | isSendingData: boolean; 36 | activeHelpMessage: string; 37 | }; 38 | 39 | const initialState: RedirectFormState = { 40 | host: '', 41 | sourceUriPath: '', 42 | targetUriPath: '', 43 | statusCode: -1, 44 | startDateTime: '', 45 | endDateTime: '', 46 | comment: '', 47 | isSendingData: false, 48 | activeHelpMessage: '', 49 | }; 50 | 51 | export class RedirectForm extends PureComponent { 52 | static contextType = RedirectContext; 53 | 54 | protected sourceUriPathInputRef: React.RefObject; 55 | 56 | constructor(props: RedirectFormProps) { 57 | super(props); 58 | this.state = { 59 | ...initialState, 60 | ...props.redirect, 61 | }; 62 | 63 | this.sourceUriPathInputRef = React.createRef(); 64 | } 65 | 66 | public componentDidMount(): void { 67 | // Context cannot be accessed in the constructor therefore set the default here is necessary 68 | if (this.state.statusCode === -1) { 69 | this.setState({ statusCode: this.context.defaultStatusCode }); 70 | } 71 | } 72 | 73 | /** 74 | * Edits an existing redirect or creates a new one 75 | * 76 | * @param event 77 | */ 78 | private handleSubmit = (event: React.FormEvent): void => { 79 | event.preventDefault(); 80 | 81 | const { redirect, notificationHelper, actions, handleNewRedirect, handleUpdatedRedirect, translate } = 82 | this.props; 83 | 84 | const { csrfToken, defaultStatusCode } = this.context; 85 | 86 | const { startDateTime, endDateTime, statusCode, sourceUriPath, targetUriPath } = this.state; 87 | let { host } = this.state; 88 | const finalStatusCode = statusCode > 0 ? statusCode : defaultStatusCode; 89 | 90 | // Replace a single asterisk with an empty value to match any domain 91 | host = host && host.trim() === '*' ? '' : host; 92 | 93 | if (!host || host === location.host) { 94 | const parsedSourceUrl: URL = UrlUtil.parseURL(sourceUriPath, location.origin); 95 | const parsedTargetUrl: URL = UrlUtil.parseURL(targetUriPath, location.origin); 96 | if (parsedSourceUrl.pathname === parsedTargetUrl.pathname) { 97 | notificationHelper.warning( 98 | translate('error.sameSourceAndTarget', 'The source and target paths cannot be the same') 99 | ); 100 | return; 101 | } 102 | } 103 | 104 | const validStartDateTimeString = 105 | startDateTime.indexOf('T') === -1 ? startDateTime.replace(' ', 'T') + 'Z' : startDateTime; 106 | const validStartDateTime = startDateTime ? new Date(validStartDateTimeString) : null; 107 | const validEndDateTimeString = 108 | endDateTime.indexOf('T') === -1 ? endDateTime.replace(' ', 'T') + 'Z' : endDateTime; 109 | const validEndDateTime = endDateTime ? new Date(validEndDateTimeString) : null; 110 | 111 | const data = { 112 | __csrfToken: csrfToken, 113 | moduleArguments: { 114 | originalHost: redirect ? redirect.host : null, 115 | originalSourceUriPath: redirect ? redirect.sourceUriPath : null, 116 | ...this.state, 117 | host, 118 | targetUriPath: Helpers.statusCodeSupportsTarget(finalStatusCode) ? targetUriPath : '/', 119 | startDateTime: validStartDateTime ? DateTimeUtil.formatW3CString(validStartDateTime) : null, 120 | endDateTime: validEndDateTime ? DateTimeUtil.formatW3CString(validEndDateTime) : null, 121 | }, 122 | }; 123 | 124 | this.setState({ isSendingData: true }); 125 | 126 | this.postRedirect(redirect ? actions.update : actions.create, data) 127 | .then((data) => { 128 | const { messages, changedRedirects } = data; 129 | 130 | // Depending on whether an existing redirect was edited handle the list of changes but keep the original 131 | if (redirect) { 132 | handleUpdatedRedirect(changedRedirects.slice(), redirect); 133 | } else { 134 | handleNewRedirect(changedRedirects.slice()); 135 | 136 | // Reset form when a redirect was created but not when it was just updated 137 | this.setState({ 138 | ...initialState, 139 | statusCode: this.state.statusCode, 140 | isSendingData: false, 141 | }); 142 | 143 | this.sourceUriPathInputRef.current.focus(); 144 | } 145 | 146 | if (changedRedirects.length > 1) { 147 | const changeList = this.renderChangedRedirects(changedRedirects); 148 | notificationHelper.warning(translate('message.updatedRedirects', 'Changed redirects'), changeList); 149 | } 150 | messages.forEach(({ title, message, severity }) => { 151 | notificationHelper[severity.toLowerCase()](title || message, message); 152 | }); 153 | }) 154 | .catch(() => { 155 | this.setState({ 156 | isSendingData: false, 157 | }); 158 | }); 159 | }; 160 | 161 | private postRedirect = (path: string, body?: any): Promise => { 162 | const { notificationHelper } = this.props; 163 | 164 | return fetch(path, { 165 | method: 'POST', 166 | credentials: 'include', 167 | headers: { 168 | 'Content-Type': 'application/json; charset=UTF-8', 169 | }, 170 | body: body && JSON.stringify(body), 171 | }) 172 | .then((res) => res.json()) 173 | .then(async (data) => { 174 | if (data.success) { 175 | return data; 176 | } 177 | data.messages.forEach(({ title, message, severity }) => { 178 | notificationHelper[severity.toLowerCase()](title || message, message); 179 | }); 180 | throw new Error(); 181 | }); 182 | }; 183 | 184 | /** 185 | * Stores any change to the form in the state 186 | * 187 | * @param event 188 | */ 189 | private handleInputChange = (event: ChangeEvent): void => { 190 | const target: HTMLInputElement = event.target as HTMLInputElement; 191 | const { name, value } = target; 192 | this.setState({ 193 | [name]: value.substring(0, MAX_INPUT_LENGTH), 194 | }); 195 | }; 196 | 197 | /** 198 | * Stores changes to datetime fields in the state 199 | * 200 | * @param property 201 | * @param datetime 202 | */ 203 | private handleDatePickerChange(property: string, datetime: Date | string): void { 204 | const formattedValue = 205 | typeof datetime === 'string' ? datetime : datetime ? DateTimeUtil.formatReadable(datetime) : ''; 206 | this.setState({ 207 | [property]: formattedValue, 208 | }); 209 | } 210 | 211 | /** 212 | * Renders a datepicker 213 | * 214 | * @param property 215 | * @param dateTimeString 216 | * @param placeholder 217 | */ 218 | private renderDatePicker = (property: string, dateTimeString: string, placeholder: string): React.ReactElement => { 219 | const { translate } = this.props; 220 | // We need to modify the format to make it valid for all browsers (Safari, Firefox, etc...) 221 | const validDateTimeString = 222 | dateTimeString.indexOf('T') === -1 ? dateTimeString.replace(' ', 'T') + 'Z' : dateTimeString; 223 | const dateTime = dateTimeString ? new Date(validDateTimeString) : null; 224 | 225 | return ( 226 | this.handleDatePickerChange(property, value)} 237 | /> 238 | ); 239 | }; 240 | 241 | /** 242 | * Renders list of changed redirects to be used in a flash message 243 | * 244 | * @param changedRedirects 245 | */ 246 | private renderChangedRedirects = (changedRedirects: Redirect[]): string => { 247 | const { translate } = this.props; 248 | return ` 249 |

    ${translate('message.relatedChanges', 'Related changes')}

    250 |
      251 | ${changedRedirects 252 | .map( 253 | (redirect) => 254 | `
    • ${redirect.host || ''}/${redirect.sourceUriPath}→${redirect.targetUriPath}
    • ` 255 | ) 256 | .join('')} 257 |
    `; 258 | }; 259 | 260 | /** 261 | * Sets a help message active 262 | * 263 | * @param identifier 264 | */ 265 | private toggleHelpMessage = (identifier: string): void => { 266 | const { activeHelpMessage } = this.state; 267 | this.setState({ activeHelpMessage: activeHelpMessage === identifier ? '' : identifier }); 268 | }; 269 | 270 | public render(): React.ReactElement { 271 | const { translate, redirect, idPrefix, validSourceUriPathPattern, handleCancelAction } = this.props; 272 | 273 | const { statusCodes, hostOptions } = this.context; 274 | 275 | const { 276 | host, 277 | sourceUriPath, 278 | targetUriPath, 279 | statusCode, 280 | startDateTime, 281 | endDateTime, 282 | comment, 283 | isSendingData, 284 | activeHelpMessage, 285 | } = this.state; 286 | 287 | return ( 288 |
    this.handleSubmit(e)} className="add-redirect-form"> 289 |
    290 |
    291 | 294 | 306 | {hostOptions && ( 307 | 308 | {hostOptions.map((hostOption: string) => ( 309 | 312 | ))} 313 | 314 | )} 315 |
    316 |
    317 | 331 | 348 |
    349 |
    350 |
    351 |
    352 | 355 | 377 |
    378 | {Helpers.statusCodeSupportsTarget(statusCode) && ( 379 |
    380 | 383 | 396 |
    397 | )} 398 |
    399 |
    400 |
    401 | 402 | {this.renderDatePicker( 403 | 'startDateTime', 404 | startDateTime, 405 | translate('startDateTime.placeholder', 'Enter start date') 406 | )} 407 |
    408 |
    409 | 410 | {this.renderDatePicker( 411 | 'endDateTime', 412 | endDateTime, 413 | translate('endDateTime.placeholder', 'Enter end date') 414 | )} 415 |
    416 |
    417 | 420 |
    421 |