[FEATURE] Add system extension "redirects" 58/55358/50
authorBenni Mack <benni@typo3.org>
Sat, 13 Jan 2018 21:31:58 +0000 (22:31 +0100)
committerSusanne Moog <susanne.moog@typo3.org>
Tue, 23 Jan 2018 15:34:53 +0000 (16:34 +0100)
A new system extension "redirects" is added, which ships a flexible
handling of HTTP redirects, useful both for marketeers and
site administrators.

It adds a new module called "Site Management => Redirects".

Site Management will be the starting point also for templating and
domain setups in the future.

A new DB table "sys_redirect" is added, which allows to configure
a redirect from a source (host+path) to a destination target.
The destination target can be any kind of Uri
(used by the LinkService).

In the short run, redirects superseeds the redirect logic from
sys_domain.redirectTo (see followup patch), but more features
are already sketched out, however, this change only
adds the basic functionality.

Any time a redirect is added or modified, a list
of all redirects is added to the cache management,
allowing to fetch all redirects at once,
reducing the number of queries to the DB in the
frontend to 1 query (or to one query to the FS, as
the power lies in the caching framework).

A simple hit statistics counter is implemented as well.

The redirects functionality later will serve
for URL Routing if a page will be registered under
a different URL, and a redirect could automatically be added.

Further improvements (out of scope for this change):
- Move icons into the TYPO3 iconset
- Check for recursive / loops, or existing redirects
- Add further conditions for redirects
- Export redirects as VCL, nginx or .htaccess rules for performance reasons
- Bulk import of redirects
- Selection of existing sys_domain redirects in source_
- Sanitize source_host to only include a domain name, and/or allow ports
- Allow query parameters in source_path

Resolves: #83631
Releases: master
Change-Id: Ibf25c2ee07f41edbaf14b97a7f115d36f901cc62
Reviewed-on: https://review.typo3.org/55358
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Joerg Boesche <typo3@joergboesche.de>
Tested-by: Joerg Boesche <typo3@joergboesche.de>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Reiner Teubner <reiner.teubner@me.com>
Tested-by: Reiner Teubner <reiner.teubner@me.com>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Tested-by: Anja Leichsenring <aleichsenring@ab-softlab.de>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Tobi Kretschmann <tobi@tobishome.de>
Tested-by: Tobi Kretschmann <tobi@tobishome.de>
Reviewed-by: Susanne Moog <susanne.moog@typo3.org>
Tested-by: Susanne Moog <susanne.moog@typo3.org>
26 files changed:
composer.json
typo3/sysext/core/Classes/Utility/HttpUtility.php
typo3/sysext/core/Documentation/Changelog/master/Feature-83631-SystemExtensionRedirectsHasBeenAdded.rst [new file with mode: 0644]
typo3/sysext/redirects/Classes/Controller/ManagementController.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Evaluation/SourceHost.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Evaluation/SourcePath.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/FormDataProvider/ValuePickerItemDataProvider.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Hooks/DataHandlerCacheFlushingHook.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Http/RedirectHandler.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Service/RedirectCacheService.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/Service/RedirectService.php [new file with mode: 0644]
typo3/sysext/redirects/Classes/ViewHelpers/EditRecordViewHelper.php [new file with mode: 0644]
typo3/sysext/redirects/Configuration/TCA/sys_redirect.php [new file with mode: 0644]
typo3/sysext/redirects/Resources/Private/Language/locallang_db.xlf [new file with mode: 0644]
typo3/sysext/redirects/Resources/Private/Language/locallang_module_redirect.xlf [new file with mode: 0644]
typo3/sysext/redirects/Resources/Private/Layouts/RedirectAdministration.html [new file with mode: 0644]
typo3/sysext/redirects/Resources/Private/Templates/Management/Overview.html [new file with mode: 0644]
typo3/sysext/redirects/Resources/Public/Icons/Extension.png [new file with mode: 0644]
typo3/sysext/redirects/Resources/Public/Icons/repeat_64x64.png [new file with mode: 0644]
typo3/sysext/redirects/Tests/Unit/FormDataProvider/ValuePickerItemDataProviderTest.php [new file with mode: 0644]
typo3/sysext/redirects/Tests/Unit/Service/RedirectServiceTest.php [new file with mode: 0644]
typo3/sysext/redirects/composer.json [new file with mode: 0644]
typo3/sysext/redirects/ext_emconf.php [new file with mode: 0644]
typo3/sysext/redirects/ext_localconf.php [new file with mode: 0644]
typo3/sysext/redirects/ext_tables.php [new file with mode: 0644]
typo3/sysext/redirects/ext_tables.sql [new file with mode: 0644]

index 016a44d..4986828 100644 (file)
                        "TYPO3\\CMS\\Opendocs\\": "typo3/sysext/opendocs/Classes/",
                        "TYPO3\\CMS\\Recordlist\\": "typo3/sysext/recordlist/Classes/",
                        "TYPO3\\CMS\\Recycler\\": "typo3/sysext/recycler/Classes/",
+                       "TYPO3\\CMS\\Redirects\\": "typo3/sysext/redirects/Classes/",
                        "TYPO3\\CMS\\Reports\\": "typo3/sysext/reports/Classes/",
                        "TYPO3\\CMS\\Rsaauth\\": "typo3/sysext/rsaauth/Classes/",
                        "TYPO3\\CMS\\RteCKEditor\\": "typo3/sysext/rte_ckeditor/Classes/",
index a7ef021..12b9c9b 100644 (file)
@@ -14,6 +14,8 @@ namespace TYPO3\CMS\Core\Utility;
  * The TYPO3 project - inspiring people to share!
  */
 
+use Psr\Http\Message\ResponseInterface;
+
 /**
  * HTTP Utility class
  */
@@ -119,4 +121,22 @@ class HttpUtility
             (isset($urlParts['query']) ? '?' . $urlParts['query'] : '') .
             (isset($urlParts['fragment']) ? '#' . $urlParts['fragment'] : '');
     }
+
+    /**
+     * Send Response to client and exit
+     *
+     * @param ResponseInterface $response
+     * @internal not part of public/stable API yet
+     */
+    public static function sendResponse(ResponseInterface $response)
+    {
+        if (!headers_sent()) {
+            header('HTTP/' . $response->getProtocolVersion() . ' ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase());
+            foreach ($response->getHeaders() as $name => $values) {
+                header($name . ': ' . implode(', ', $values));
+            }
+        }
+        echo $response->getBody()->__toString();
+        exit;
+    }
 }
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-83631-SystemExtensionRedirectsHasBeenAdded.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-83631-SystemExtensionRedirectsHasBeenAdded.rst
new file mode 100644 (file)
index 0000000..d676299
--- /dev/null
@@ -0,0 +1,44 @@
+.. include:: ../../Includes.txt
+
+=============================================================
+Feature: #83631 - System Extension "redirects" has been added
+=============================================================
+
+See :issue:`83631`
+
+Description
+===========
+
+A new system extension "redirects" has been added, which ships a flexible handling of HTTP redirects,
+useful both for marketeers and site administrators.
+
+It adds a new module called "Redirects" (under a new main module called "Site Management").
+
+A new DB table "sys_redirect" is added, which allows to configure a redirect from a source
+(host+path) to a destination target. The destination target can be any kind of Uri (used by the LinkService).
+
+Any time a redirect is added or modified, a list of all redirects is added to the cache management,
+allowing to fetch all redirects at once, reducing the number of queries to the DB in the frontend to 1 query
+(or to one query to the file system, as the power lies in the caching framework).
+
+A simple hit statistics counter is implemented as well.
+
+
+Impact
+======
+
+A system extension "Redirects" was added with the following features:
+
+* A new sub module "Redirects"
+* Possibility to add redirects with the following caveats
+** Source may be a specific domain, domain with port or "any" domain
+** Source Path may be an absolute path (`/foo/bar/`) or a regular expression (`#f(+*?)#`)
+** Target may be selected with the link wizard (and may be a page, file, folder or external URL)
+* The target can be forced to HTTPS only
+* The status code of the redirect can be configured per redirect
+* Existing GET variables can be kept through the redirect
+* Redirects can be set up for specific time frames or indefinitely
+* An `X-Redirect-By: TYPO3` header is added to each redirect initiated by the module
+* A simple database based hit counter shows how often a redirect was executed and may be manually resetted
+
+.. index:: Backend, Frontend, NotScanned
diff --git a/typo3/sysext/redirects/Classes/Controller/ManagementController.php b/typo3/sysext/redirects/Classes/Controller/ManagementController.php
new file mode 100644 (file)
index 0000000..0bff003
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Controller;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Backend\Template\Components\ButtonBar;
+use TYPO3\CMS\Backend\Template\ModuleTemplate;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Http\HtmlResponse;
+use TYPO3\CMS\Core\Imaging\Icon;
+use TYPO3\CMS\Core\Imaging\IconFactory;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Fluid\View\StandaloneView;
+use TYPO3\CMS\Redirects\Service\RedirectCacheService;
+use TYPO3Fluid\Fluid\View\ViewInterface;
+
+/**
+ * Lists all redirects in the TYPO3 Backend as a module
+ */
+class ManagementController
+{
+    /**
+     * ModuleTemplate object
+     *
+     * @var ModuleTemplate
+     */
+    protected $moduleTemplate;
+
+    /**
+     * @var ViewInterface
+     */
+    protected $view;
+
+    /**
+     * @var ServerRequestInterface
+     */
+    protected $request;
+
+    /**
+     * @var IconFactory
+     */
+    protected $iconFactory;
+
+    /**
+     * Instantiate the form protection before a simulated user is initialized.
+     */
+    public function __construct()
+    {
+        $this->moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class);
+        $this->moduleTemplate->getPageRenderer()->loadRequireJsModule('TYPO3/CMS/Backend/Modal');
+        $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
+        $this->getLanguageService()->includeLLFile('EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf');
+    }
+
+    /**
+     * Injects the request object for the current request, and renders the overview of all redirects
+     *
+     * @param ServerRequestInterface $request the current request
+     * @return ResponseInterface the response with the content
+     */
+    public function handleRequest(ServerRequestInterface $request): ResponseInterface
+    {
+        $this->request = $request;
+        $action = $request->getQueryParams()['action'] ?? $request->getParsedBody()['action'] ?? 'overview';
+        $this->initializeView($action);
+
+        $result = call_user_func_array([$this, $action . 'Action'], [$request]);
+        if ($result instanceof ResponseInterface) {
+            return $result;
+        }
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Show all redirects, and add a button to create a new redirect
+     */
+    protected function overviewAction()
+    {
+        $this->getButtons();
+
+        $redirects = GeneralUtility::makeInstance(RedirectCacheService::class)->getAllRedirects();
+        $this->view->assign('redirects', $redirects);
+    }
+
+    /**
+     * @param string $templateName
+     */
+    protected function initializeView(string $templateName)
+    {
+        $this->view = GeneralUtility::makeInstance(StandaloneView::class);
+        $this->view->setTemplate($templateName);
+        $this->view->setTemplateRootPaths(['EXT:redirects/Resources/Private/Templates/Management']);
+        $this->view->setPartialRootPaths(['EXT:redirects/Resources/Private/Partials']);
+        $this->view->setLayoutRootPaths(['EXT:redirects/Resources/Private/Layouts']);
+    }
+
+    /**
+     * Create document header buttons
+     */
+    protected function getButtons()
+    {
+        /** @var UriBuilder $uriBuilder */
+        $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
+
+        $buttonBar = $this->moduleTemplate->getDocHeaderComponent()->getButtonBar();
+
+        // Create new
+        $newRecordButton = $buttonBar->makeLinkButton()
+            ->setHref((string)$uriBuilder->buildUriFromRoute(
+                'record_edit',
+                [
+                    'edit' => ['sys_redirect' => ['new'],
+                ],
+                'returnUrl' => (string)$uriBuilder->buildUriFromRoute('site_redirects'),
+            ]
+            ))
+            ->setTitle($this->getLanguageService()->getLL('redirect_add_text'))
+            ->setIcon($this->iconFactory->getIcon('actions-add', Icon::SIZE_SMALL));
+        $buttonBar->addButton($newRecordButton, ButtonBar::BUTTON_POSITION_LEFT, 10);
+
+        // Reload
+        $reloadButton = $buttonBar->makeLinkButton()
+            ->setHref(GeneralUtility::getIndpEnv('REQUEST_URI'))
+            ->setTitle($this->getLanguageService()->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.reload'))
+            ->setIcon($this->iconFactory->getIcon('actions-refresh', Icon::SIZE_SMALL));
+        $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT);
+
+        // Shortcut
+        $mayMakeShortcut = $this->getBackendUserAuthentication()->mayMakeShortcut();
+        if ($mayMakeShortcut) {
+            $getVars = ['id', 'route'];
+
+            $shortcutButton = $buttonBar->makeShortcutButton()
+                ->setModuleName('site_redirects')
+                ->setGetVariables($getVars);
+            $buttonBar->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT);
+        }
+    }
+
+    /**
+     * @return LanguageService
+     */
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+
+    /**
+     * @return BackendUserAuthentication
+     */
+    protected function getBackendUserAuthentication(): BackendUserAuthentication
+    {
+        return $GLOBALS['BE_USER'];
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Evaluation/SourceHost.php b/typo3/sysext/redirects/Classes/Evaluation/SourceHost.php
new file mode 100644 (file)
index 0000000..0156ff2
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Evaluation;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+/**
+ * Class SourceHost - Used for validation / sanitation of domain values
+ */
+class SourceHost
+{
+    /**
+     * Server-side removing of protocol on save
+     *
+     * @param string $value The field value to be evaluated
+     * @param string $is_in The "is_in" value of the field configuration from TCA
+     * @param bool $set Boolean defining if the value is written to the database or not.
+     * @return string Evaluated field value
+     */
+    public function evaluateFieldValue($value, $isIn, &$set)
+    {
+        return preg_replace('#(.*?:\/\/)#', '', $value);
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Evaluation/SourcePath.php b/typo3/sysext/redirects/Classes/Evaluation/SourcePath.php
new file mode 100644 (file)
index 0000000..a730b68
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Evaluation;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+/**
+ * Class SourcePath - Used for validation / sanitation of url path segments
+ */
+class SourcePath
+{
+
+    /**
+     * JavaScript code for client side validation/evaluation
+     *
+     * @return string JavaScript code for client side validation/evaluation
+     */
+    public function returnFieldJS(): string
+    {
+        return
+            'if (value.charAt(0) != "/") { value = "/" + value; };' .
+            'if (value.charAt(value.length-1) != "/") { value = value + "/"; };' .
+            'value = value.replace(/\/\//g, "/");' .
+            'value = value.replace(/ß/g, "ss");' .
+            'value = value.replace(/ü/g, "ue");' .
+            'value = value.replace(/ä/g, "ae");' .
+            'value = value.replace(/ö/g, "oe");' .
+            'return value.replace(/[\s*\"\'¢|°\^!?=<>§&$%@{}()[\]]/g, "");';
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/FormDataProvider/ValuePickerItemDataProvider.php b/typo3/sysext/redirects/Classes/FormDataProvider/ValuePickerItemDataProvider.php
new file mode 100644 (file)
index 0000000..e383e12
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\FormDataProvider;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Backend\Form\FormDataProviderInterface;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Inject sys_domain records into valuepicker form
+ */
+class ValuePickerItemDataProvider implements FormDataProviderInterface
+{
+
+    /**
+     * Add sys_domains into $result data array
+     *
+     * @param array $result Initialized result array
+     * @return array Result filled with more data
+     */
+    public function addData(array $result): array
+    {
+        if ($result['tableName'] === 'sys_redirect' && isset($result['processedTca']['columns']['source_host'])) {
+            $domains = $this->getDomains();
+            foreach ($domains as $domain) {
+                $result['processedTca']['columns']['source_host']['config']['valuePicker']['items'][] =
+                [
+                    $domain['domainName'],
+                    $domain['domainName'],
+                ];
+            }
+        }
+        return $result;
+    }
+
+    /**
+     * Get sys_domain records from database
+     *
+     * @return array domain records
+     */
+    public function getDomains(): array
+    {
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_domain');
+        $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(HiddenRestriction::class));
+        $domains = $queryBuilder
+            ->select('domainName')
+            ->from('sys_domain')
+            ->execute()
+            ->fetchAll();
+        return $domains;
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Hooks/DataHandlerCacheFlushingHook.php b/typo3/sysext/redirects/Classes/Hooks/DataHandlerCacheFlushingHook.php
new file mode 100644 (file)
index 0000000..3d7dfe2
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Hooks;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\DataHandling\DataHandler;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Redirects\Service\RedirectCacheService;
+
+/**
+ * Ensure to clear the cache entry when a sys_redirect record is modified or deleted
+ */
+class DataHandlerCacheFlushingHook
+{
+    /**
+     * Check if the data handler processed a sys_redirect record, if so, rebuild the redirect index cache
+     *
+     * @param array $parameters unused
+     * @param DataHandler $dataHandler the data handler object
+     */
+    public function rebuildRedirectCacheIfNecessary(array $parameters, DataHandler $dataHandler)
+    {
+        if (isset($dataHandler->datamap['sys_redirect']) || isset($dataHandler->cmdmap['sys_redirect'])) {
+            GeneralUtility::makeInstance(RedirectCacheService::class)->rebuild();
+        }
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Http/RedirectHandler.php b/typo3/sysext/redirects/Classes/Http/RedirectHandler.php
new file mode 100644 (file)
index 0000000..837cb3d
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+declare(strict_types=1);
+namespace TYPO3\CMS\Redirects\Http;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\UriInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Http\RedirectResponse;
+use TYPO3\CMS\Core\Http\ServerRequestFactory;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\HttpUtility;
+use TYPO3\CMS\Redirects\Service\RedirectService;
+
+/**
+ * Hooks into the frontend request, and checks if a redirect should apply,
+ * If so, a redirect response is triggered.
+ */
+class RedirectHandler implements LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    /**
+     * First hook within the Frontend Request handling
+     */
+    public function handle()
+    {
+        $redirectService = GeneralUtility::makeInstance(RedirectService::class);
+        //@todo The request object should be handed in by the hook in the future
+        $currentRequest = ServerRequestFactory::fromGlobals();
+        $port = $currentRequest->getUri()->getPort();
+        $matchedRedirect = $redirectService->matchRedirect(
+            $currentRequest->getUri()->getHost() . ($port ? ':' . $port : ''),
+            $currentRequest->getUri()->getPath()
+        );
+
+        // If the matched redirect is found, resolve it, and check further
+        if (!is_array($matchedRedirect)) {
+            return;
+        }
+
+        $url = $redirectService->getTargetUrl($matchedRedirect, $currentRequest->getQueryParams());
+        if ($url instanceof UriInterface) {
+            $this->logger->debug('Redirecting', ['record' => $matchedRedirect, 'uri' => $url]);
+            $response = $this->buildRedirectResponse($url, $matchedRedirect);
+            $this->incrementHitCount($matchedRedirect);
+            HttpUtility::sendResponse($response);
+        }
+    }
+
+    /**
+     * Creates a PSR-7 compatible Response object
+     *
+     * @param UriInterface $uri
+     * @param array $redirectRecord
+     * @return ResponseInterface
+     */
+    protected function buildRedirectResponse(UriInterface $uri, array $redirectRecord): ResponseInterface
+    {
+        return new RedirectResponse($uri, (int)$redirectRecord['target_statuscode'], ['X-Redirect-By' => 'TYPO3']);
+    }
+
+    /**
+     * Updates the sys_record's hit counter by one
+     *
+     * @param array $redirectRecord
+     */
+    protected function incrementHitCount(array $redirectRecord)
+    {
+        // Track the hit if not disabled
+        if ($redirectRecord['disable_hitcount']) {
+            return;
+        }
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getQueryBuilderForTable('sys_redirect');
+        $queryBuilder
+            ->update('sys_redirect')
+            ->where(
+                $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($redirectRecord['uid'], \PDO::PARAM_INT))
+            )
+            ->set('hitcount', $queryBuilder->quoteIdentifier('hitcount') . '+1', false)
+            ->set('lasthiton', $GLOBALS['EXEC_TIME'])
+            ->execute();
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Service/RedirectCacheService.php b/typo3/sysext/redirects/Classes/Service/RedirectCacheService.php
new file mode 100644 (file)
index 0000000..b4c6028
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Service;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Cache\CacheManager;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Ensure to clear the cache entry when a sys_redirect record is modified, also the main pool
+ * for getting all redirects.
+ *
+ * @internal
+ */
+class RedirectCacheService
+{
+    /**
+     * @var \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
+     */
+    protected $cache;
+
+    /**
+     * Constructor setting up the cache
+     * @param CacheManager|null $cacheManager
+     * @throws \TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException
+     */
+    public function __construct(CacheManager $cacheManager = null)
+    {
+        $cacheManager = $cacheManager ?? GeneralUtility::makeInstance(CacheManager::class);
+        $this->cache = $cacheManager->getCache('cache_pages');
+    }
+
+    /**
+     * Fetches all redirects available to the system, grouped by domain and regexp/nonregexp
+     *
+     * @return array
+     */
+    public function getRedirects(): array
+    {
+        $redirects = $this->cache->get('redirects');
+        if (!is_array($redirects)) {
+            $this->rebuild();
+            $redirects = $this->cache->get('redirects');
+        }
+        return $redirects;
+    }
+
+    /**
+     * Rebuilds the cache for all redirects, grouped by host and by regular expressions.
+     * Does not include deleted redirects, but includes the ones with dynamic starttime/endtime.
+     */
+    public function rebuild()
+    {
+        $redirects = [];
+        $this->flush();
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_redirect');
+        $queryBuilder->getRestrictions()->removeAll()
+            ->add(GeneralUtility::makeInstance(HiddenRestriction::class))
+            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
+        $statement = $queryBuilder
+            ->select('*')
+            ->from('sys_redirect')
+            ->execute();
+        while ($row = $statement->fetch()) {
+            $host = $row['source_host'] ?: '*';
+            if ($row['is_regexp']) {
+                $redirects[$host]['regexp'][$row['source_path']][$row['uid']] = $row;
+            } else {
+                $redirects[$host]['flat'][rtrim($row['source_path'], '/') . '/'][$row['uid']] = $row;
+            }
+        }
+        $this->cache->set('redirects', $redirects, ['redirects']);
+    }
+
+    /**
+     * Used within the backend module, which also includes the hidden records
+     * @return array
+     */
+    public function getAllRedirects(): array
+    {
+        $redirects = [];
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_redirect');
+        $queryBuilder->getRestrictions()->removeAll()
+            ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
+        $statement = $queryBuilder
+            ->select('*')
+            ->from('sys_redirect')
+            ->execute();
+        while ($row = $statement->fetch()) {
+            $host = $row['source_host'] ?: '*';
+            if ($row['is_regexp']) {
+                $redirects[$host]['regexp'][$row['source_path']][$row['uid']] = $row;
+            } else {
+                $redirects[$host]['flat'][rtrim($row['source_path'], '/') . '/'][$row['uid']] = $row;
+            }
+        }
+        return $redirects;
+    }
+
+    /**
+     * Flushes all redirects from the cache
+     */
+    protected function flush()
+    {
+        $this->cache->flushByTag('redirects');
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/Service/RedirectService.php b/typo3/sysext/redirects/Classes/Service/RedirectService.php
new file mode 100644 (file)
index 0000000..dbcaaf8
--- /dev/null
@@ -0,0 +1,258 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Service;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\UriInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use TYPO3\CMS\Core\Http\Uri;
+use TYPO3\CMS\Core\LinkHandling\LinkService;
+use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
+use TYPO3\CMS\Core\Resource\File;
+use TYPO3\CMS\Core\Resource\Folder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
+use TYPO3\CMS\Frontend\Typolink\AbstractTypolinkBuilder;
+use TYPO3\CMS\Frontend\Typolink\UnableToLinkException;
+
+/**
+ * Creates a proper URL to redirect from a matched redirect of a request
+ *
+ * @internal due to some possible refactorings in TYPO3 v9
+ */
+class RedirectService implements LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    /**
+     * Checks against all available redirects "flat" or "regexp", and against starttime/endtime
+     *
+     * @param string $domain
+     * @param string $path
+     * @return array|null
+     */
+    public function matchRedirect(string $domain, string $path)
+    {
+        $allRedirects = $this->fetchRedirects();
+        // Check if the domain matches, or if there is a
+        // redirect fitting for any domain
+        foreach ([$domain, '*'] as $domainName) {
+            if (empty($allRedirects[$domainName])) {
+                continue;
+            }
+
+            $possibleRedirects = [];
+            // match if a flat redirect matches
+            if (!empty($allRedirects[$domainName]['flat'][rtrim($path, '/') . '/'])) {
+                $possibleRedirects = $allRedirects[$domainName]['flat'][rtrim($path, '/') . '/'];
+            }
+            // check all redirects that are registered as regex
+            if (!empty($allRedirects[$domainName]['regexp'])) {
+                $allRegexps = array_keys($allRedirects[$domainName]['regexp']);
+                foreach ($allRegexps as $regexp) {
+                    if (preg_match($regexp, $path)) {
+                        $possibleRedirects += $allRedirects[$domainName]['regexp'][$regexp];
+                    }
+                }
+            }
+
+            foreach ($possibleRedirects as $possibleRedirect) {
+                // check starttime and endtime for all existing records
+                if ($this->isRedirectActive($possibleRedirect)) {
+                    return $possibleRedirect;
+                }
+            }
+        }
+    }
+
+    /**
+     * Check if a redirect record matches the starttime and endtime and disable restrictions
+     *
+     * @param array $redirectRecord
+     *
+     * @return bool whether the redirect is active and should be used for redirecting the current request
+     */
+    protected function isRedirectActive(array $redirectRecord): bool
+    {
+        return !$redirectRecord['disabled'] && $redirectRecord['starttime'] <= $GLOBALS['SIM_ACCESS_TIME'] &&
+               (!$redirectRecord['endtime'] || $redirectRecord['endtime'] >= $GLOBALS['SIM_ACCESS_TIME']);
+    }
+
+    /**
+     * Fetches all redirects from the DB and caches them, grouped by the domain
+     * does NOT take starttime/endtime into account, as it is cached.
+     *
+     * @return array
+     */
+    protected function fetchRedirects(): array
+    {
+        return GeneralUtility::makeInstance(RedirectCacheService::class)->getRedirects();
+    }
+
+    /**
+     * Check if the current request is actually a redirect, and then process the redirect.
+     *
+     * @param string $redirectTarget
+     *
+     * @return array the link details from the linkService
+     */
+    protected function resolveLinkDetailsFromLinkTarget(string $redirectTarget): array
+    {
+        // build the target URL, take force SSL into account etc.
+        $linkService = GeneralUtility::makeInstance(LinkService::class);
+        try {
+            $linkDetails = $linkService->resolve($redirectTarget);
+            switch ($linkDetails['type']) {
+                case LinkService::TYPE_URL:
+                    // all set up, nothing to do
+                    break;
+                case LinkService::TYPE_FILE:
+                    /** @var File $file */
+                    $file = $linkDetails['file'];
+                    if ($file instanceof File) {
+                        $linkDetails['url'] = $file->getPublicUrl();
+                    }
+                    break;
+                case LinkService::TYPE_FOLDER:
+                    /** @var Folder $folder */
+                    $folder = $linkDetails['folder'];
+                    if ($folder instanceof Folder) {
+                        $linkDetails['url'] = $folder->getPublicUrl();
+                    }
+                    break;
+                default:
+                    // we have to return the link details without having a "URL" parameter
+
+            }
+        } catch (InvalidPathException $e) {
+            return [];
+        }
+        return $linkDetails;
+    }
+
+    /**
+     * @param array $matchedRedirect
+     * @param array $queryParams
+     * @return UriInterface|Uri|null
+     */
+    public function getTargetUrl(array $matchedRedirect, array $queryParams)
+    {
+        $this->logger->debug('Found a redirect to process', $matchedRedirect);
+        $linkDetails = $this->resolveLinkDetailsFromLinkTarget((string)$matchedRedirect['target']);
+        $this->logger->debug('Resolved link details for redirect', $linkDetails);
+        // Do this for files, folders, external URLs
+        if ($linkDetails['url']) {
+            $url = new Uri($linkDetails['url']);
+            if ($matchedRedirect['force_https']) {
+                $url = $url->withScheme('https');
+            }
+            if ($matchedRedirect['keep_query_parameters']) {
+                $url = $this->addQueryParams($queryParams, $url);
+            }
+        } else {
+            // If it's a record or page, then boot up TSFE
+            $url = $this->getUriFromCustomLinkDetails($linkDetails, $matchedRedirect);
+        }
+        return $url;
+    }
+
+    /**
+     * Adds query parameters to a Uri object
+     *
+     * @param array $queryParams
+     * @param Uri $url
+     * @return Uri
+     */
+    protected function addQueryParams(array $queryParams, Uri $url): Uri
+    {
+        // New query parameters overrule the ones that should be kept
+        $newQueryParamString = $url->getQuery();
+        if (!empty($newQueryParamString)) {
+            $newQueryParams = GeneralUtility::explodeUrl2Array($newQueryParamString, true);
+            $queryParams = array_replace_recursive($queryParams, $newQueryParams);
+        }
+        $query = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
+        if ($query) {
+            $url = $url->withQuery($query);
+        }
+        return $url;
+    }
+
+    /**
+     * Called when TypoScript/TSFE is available, so typolink is used to generate the URL
+     *
+     * @param array $linkDetails
+     * @param array $redirectRecord
+     * @return UriInterface|null
+     */
+    protected function getUriFromCustomLinkDetails(array $linkDetails, array $redirectRecord)
+    {
+        if (!isset($linkDetails['type'], $GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']])) {
+            return null;
+        }
+        $this->bootFrontendController();
+        /** @var AbstractTypolinkBuilder $linkBuilder */
+        $linkBuilder = GeneralUtility::makeInstance(
+            $GLOBALS['TYPO3_CONF_VARS']['FE']['typolinkBuilder'][$linkDetails['type']],
+            $GLOBALS['TSFE']->cObj
+        );
+        try {
+            $configuration = [
+                'forceAbsoluteUrl' => true,
+            ];
+            if ($redirectRecord['force_https']) {
+                $configuration['forceAbsoluteUrl.']['scheme'] = 'https';
+            }
+            if ($redirectRecord['keep_query_parameters']) {
+                $configuration['useCacheHash'] = false;
+                $configuration['addQueryString'] = true;
+            }
+            list($url) = $linkBuilder->build($linkDetails, '', '', $configuration);
+            return new Uri($url);
+        } catch (UnableToLinkException $e) {
+        }
+    }
+
+    /**
+     * Instantiates a TSFE object, with the first valid page ID found, after that the following properties
+     * are available
+     * - TSFE->sys_page
+     * - TSFE->tmpl
+     * - TSFE->config
+     * - TSFE->cObj
+     *
+     * So a link to a page could be generated.
+     */
+    protected function bootFrontendController()
+    {
+        // disable page errors
+        $GLOBALS['TYPO3_CONF_VARS']['FE']['pageUnavailable_handling'] = false;
+        $GLOBALS['TSFE'] = GeneralUtility::makeInstance(
+            TypoScriptFrontendController::class,
+            null,
+            GeneralUtility::_GP('id'),
+            GeneralUtility::_GP('type')
+        );
+        $GLOBALS['TSFE']->initFEuser();
+        $GLOBALS['TSFE']->initializeBackendUser();
+        $GLOBALS['TSFE']->fetch_the_id();
+        $GLOBALS['TSFE']->initTemplate();
+        $GLOBALS['TSFE']->getConfigArray();
+        $GLOBALS['TSFE']->settingLanguage();
+        $GLOBALS['TSFE']->settingLocale();
+        $GLOBALS['TSFE']->newCObj();
+    }
+}
diff --git a/typo3/sysext/redirects/Classes/ViewHelpers/EditRecordViewHelper.php b/typo3/sysext/redirects/Classes/ViewHelpers/EditRecordViewHelper.php
new file mode 100644 (file)
index 0000000..2f36097
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\ViewHelpers;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
+use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
+use TYPO3Fluid\Fluid\Core\ViewHelper\Traits\CompileWithRenderStatic;
+
+/**
+ * Edit Record ViewHelper
+ * @todo remove once general edit view helper exists
+ */
+class EditRecordViewHelper extends AbstractViewHelper
+{
+    use CompileWithRenderStatic;
+
+    /**
+     * Initializes the arguments
+     */
+    public function initializeArguments()
+    {
+        $this->registerArgument('command', 'string', 'New, Edit or Remove a Record.', true);
+        $this->registerArgument('uid', 'int', 'UID of the Record to edit.', true);
+    }
+
+    /**
+     * Render link
+     *
+     * @param array $arguments
+     * @param \Closure $renderChildrenClosure
+     * @param RenderingContextInterface $renderingContext
+     *
+     * @return string
+     * @throws \TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException
+     */
+    public static function renderStatic(array $arguments, \Closure $renderChildrenClosure, RenderingContextInterface $renderingContext): string
+    {
+        $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
+
+        switch ($arguments['command']) {
+            case 'new':
+                $urlParameters = [
+                    'edit[sys_redirect][' . $arguments['uid'] . ']' => 'new',
+                    'returnUrl' => (string)$uriBuilder->buildUriFromRoute('site_redirects'),
+                ];
+                $route = 'record_edit';
+                break;
+            case 'edit':
+                $urlParameters = [
+                    'edit[sys_redirect][' . $arguments['uid'] . ']' => 'edit',
+                    'returnUrl' => (string)$uriBuilder->buildUriFromRoute('site_redirects'),
+                ];
+                $route = 'record_edit';
+                break;
+            case 'delete':
+                $urlParameters = [
+                    'cmd[sys_redirect][' . $arguments['uid'] . '][delete]' => 1,
+                    'redirect' => GeneralUtility::getIndpEnv('REQUEST_URI'),
+                ];
+                $route = 'tce_db';
+                break;
+            case 'unhide':
+                $urlParameters = [
+                    'data[sys_redirect][' . $arguments['uid'] . '][disabled]' => 0,
+                    'redirect' => GeneralUtility::getIndpEnv('REQUEST_URI'),
+                ];
+                $route = 'tce_db';
+                break;
+            case 'hide':
+                $urlParameters = [
+                    'data[sys_redirect][' . $arguments['uid'] . '][disabled]' => 1,
+                    'redirect' => GeneralUtility::getIndpEnv('REQUEST_URI'),
+                ];
+                $route= 'tce_db';
+                break;
+            case 'resetcounter':
+                $urlParameters = [
+                    'data[sys_redirect][' . $arguments['uid'] . '][hitcount]' => 0,
+                    'redirect' => GeneralUtility::getIndpEnv('REQUEST_URI'),
+                ];
+                $route = 'tce_db';
+                break;
+            default:
+                throw new \InvalidArgumentException('Invalid command given to EditRecordViewhelper.', 1516708789);
+        }
+        return (string)$uriBuilder->buildUriFromRoute($route, $urlParameters);
+    }
+}
diff --git a/typo3/sysext/redirects/Configuration/TCA/sys_redirect.php b/typo3/sysext/redirects/Configuration/TCA/sys_redirect.php
new file mode 100644 (file)
index 0000000..8a858d5
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+
+return [
+    'ctrl' => [
+        'title' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect',
+        'label' => 'source_host',
+        'label_alt' => 'source_path',
+        'label_alt_force' => true,
+        'crdate' => 'createdon',
+        'cruser_id' => 'createdby',
+        'tstamp' => 'updatedon',
+        'versioningWS' => false,
+        'default_sortby' => 'source_host, source_path',
+        'rootLevel' => 1,
+        'security' => [
+            'ignoreWebMountRestriction' => true,
+            'ignoreRootLevelRestriction' => true,
+        ],
+        'delete' => 'deleted',
+        'enablecolumns' => [
+            'disabled' => 'disabled',
+            'starttime' => 'starttime',
+            'endtime' => 'endtime',
+        ],
+        'searchFields' => 'source_host,source_path,target,target_statuscode',
+        'iconfile' => 'EXT:redirects/Resources/Public/Icons/repeat_64x64.png',
+    ],
+    'interface' => [
+        'showRecordFieldList' => 'disabled, source_host, source_path, is_regexp, force_https, keep_query_parameters, target, target_statuscode, hitcount, lasthiton, disable_hitcount',
+    ],
+    'types' => [
+        '1' => [
+            'showitem' => '
+                --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general, --palette--;;source, --palette--;;targetdetails,
+                --div--;LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:tabs.redirectCount, disable_hitcount, hitcount, lasthiton,
+                --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, --palette--;;visibility'
+        ],
+    ],
+    'palettes' => [
+        'visibility' => [
+            'showitem' => 'disabled, --linebreak--, starttime, endtime'
+        ],
+        'source' => [
+            'showitem' => 'source_host, --linebreak--, source_path, is_regexp'
+        ],
+        'targetdetails' => [
+            'showitem' => 'target, target_statuscode, --linebreak--, force_https, keep_query_parameters'
+        ],
+    ],
+    'columns' => [
+        'disabled' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.disabled',
+            'config' => [
+                'type' => 'check',
+                'items' => [
+                    '1' => [
+                        '0' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.disabled.0'
+                    ]
+                ]
+            ]
+        ],
+        'starttime' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_general.xlf:LGL.starttime',
+            'config' => [
+                'type' => 'input',
+                'renderType' => 'inputDateTime',
+                'eval' => 'datetime',
+                'default' => 0
+            ]
+        ],
+        'endtime' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_general.xlf:LGL.endtime',
+            'config' => [
+                'type' => 'input',
+                'renderType' => 'inputDateTime',
+                'eval' => 'datetime',
+                'default' => 0,
+                'range' => [
+                    'upper' => mktime(0, 0, 0, 1, 1, 2038)
+                ]
+            ]
+        ],
+        'source_host' => [
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.source_host',
+            'config' => [
+                'type' => 'input',
+                'eval' => 'trim,required,' . \TYPO3\CMS\Redirects\Evaluation\SourceHost::class,
+                // items will be extended by local sys_domain records using dataprovider TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider
+                'valuePicker' => [
+                    'items' => [
+                        [   'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:source_host_global_text',
+                            '*',
+                        ],
+                    ],
+                ],
+                'default' => '*',
+            ],
+        ],
+        'source_path' => [
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.source_path',
+            'config' => [
+                'type' => 'input',
+                'size' => 30,
+                'eval' => 'trim,required,' . \TYPO3\CMS\Redirects\Evaluation\SourcePath::class,
+                'placeholder' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:source_path.placeholder',
+            ],
+        ],
+        'force_https' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.force_https',
+            'config' => [
+                'type' => 'check',
+                'default' => 0,
+                'items' => [
+                    '1' => [
+                        '0' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.force_https.0'
+                    ]
+                ]
+            ],
+        ],
+        'keep_query_parameters' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.keep_query_parameters',
+            'config' => [
+                'type' => 'check',
+                'default' => 0,
+                'items' => [
+                    '1' => [
+                        '0' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.keep_query_parameters.0'
+                    ]
+                ]
+            ],
+        ],
+        'target' => [
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target',
+            'config' => [
+                'type' => 'input',
+                'eval' =>'required',
+                'renderType' => 'inputLink',
+                'softref' => 'typolink',
+                'fieldControl' => [
+                    'linkPopup' => [
+                        'options' => [
+                            'blindLinkOptions' => 'mail',
+                            'blindLinkFields' => 'class, target'
+                        ],
+                    ],
+                ],
+            ],
+        ],
+        'target_statuscode' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode',
+            'config' => [
+                'type' => 'select',
+                'items' => [
+                    ['LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.301', 301],
+                    ['LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.302', 302],
+                    ['LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.303', 303],
+                    ['LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.target_statuscode.307', 307],
+                ],
+                'default' => 307,
+                'size' => 1,
+                'maxitems' => 1
+            ],
+        ],
+        'hitcount' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.hitcount',
+            'config' => [
+                'type' => 'input',
+                'size' => 5,
+                'default' => 0,
+                'readOnly' => true
+            ],
+        ],
+        'lasthiton' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.lasthiton',
+            'config' => [
+                'type' => 'input',
+                'eval' => 'datetime',
+                'renderType' => 'inputDateTime',
+                'readOnly' => true
+            ],
+        ],
+        'disable_hitcount' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.disable_hitcount',
+            'config' => [
+                'type' => 'check',
+                'items' => [
+                    '1' => [
+                        '0' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.disable_hitcount.0'
+                    ]
+                ]
+            ],
+        ],
+        'is_regexp' => [
+            'exclude' => true,
+            'label' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.is_regexp',
+            'config' => [
+                'type' => 'check',
+                'items' => [
+                    '1' => [
+                        '0' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_db.xlf:sys_redirect.is_regexp.0'
+                    ]
+                ]
+            ],
+        ],
+    ],
+];
diff --git a/typo3/sysext/redirects/Resources/Private/Language/locallang_db.xlf b/typo3/sysext/redirects/Resources/Private/Language/locallang_db.xlf
new file mode 100644 (file)
index 0000000..7b12a9b
--- /dev/null
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+       <file t3:id="1515791927" source-language="en" datatype="plaintext" original="messages" date="2017-12-29T20:22:14Z" product-name="redirects">
+               <header/>
+               <body>
+                       <trans-unit id="sys_redirect">
+                               <source>Redirect</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.source_host">
+                               <source>Source Domain</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.source_path">
+                               <source>Source Path</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.force_https">
+                               <source>SSL Redirect</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.force_https.0">
+                               <source>Force SSL Redirect</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.keep_query_parameters">
+                               <source>GET Parameters</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.keep_query_parameters.0">
+                               <source>Keep GET Parameters</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.target">
+                               <source>Target</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.target_statuscode">
+                               <source>Status Code HTTP Header</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.target_statuscode.301">
+                               <source>301 Moved Permanently</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.target_statuscode.302">
+                               <source>302 Found</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.target_statuscode.303">
+                               <source>303 See Other</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.target_statuscode.307">
+                               <source>307 Temporary Redirect</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.hitcount">
+                               <source>Count</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.lasthiton">
+                               <source>Last Hit On</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.disable_hitcount">
+                               <source>Disable Hit Counter</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.disable_hitcount.0">
+                               <source>Disable</source>
+                       </trans-unit>
+                       <trans-unit id="tabs.redirectCount">
+                               <source>Statistics</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.is_regexp">
+                               <source>Is regular expression?</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.is_regexp.0">
+                               <source>Yes</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.disabled">
+                               <source>Disable</source>
+                       </trans-unit>
+                       <trans-unit id="sys_redirect.disabled.0">
+                               <source>Deactivate redirect</source>
+                       </trans-unit>
+               </body>
+       </file>
+</xliff>
diff --git a/typo3/sysext/redirects/Resources/Private/Language/locallang_module_redirect.xlf b/typo3/sysext/redirects/Resources/Private/Language/locallang_module_redirect.xlf
new file mode 100644 (file)
index 0000000..42064f1
--- /dev/null
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+       <file t3:id="1515791952" source-language="en" datatype="plaintext" original="messages" date="2017-12-29T20:23:34Z" product-name="redirects">
+               <header/>
+               <body>
+                       <trans-unit id="mlang_labels_tablabel">
+                               <source>Redirect Administration</source>
+                       </trans-unit>
+                       <trans-unit id="mlang_labels_tabdescr">
+                               <source>This is the administration area for Web Redirects.</source>
+                       </trans-unit>
+                       <trans-unit id="mlang_tabs_tab">
+                               <source>Redirects</source>
+                       </trans-unit>
+
+                       <trans-unit id="source_host_global_text">
+                               <source>Global redirect (any domain)</source>
+                       </trans-unit>
+
+                       <trans-unit id="heading_text">
+                               <source>Redirect Management</source>
+                       </trans-unit>
+                       <trans-unit id="redirect_add_text">
+                               <source>Add redirect</source>
+                       </trans-unit>
+                       <trans-unit id="redirect_refreshview">
+                               <source>Refresh view</source>
+                       </trans-unit>
+
+                       <trans-unit id="record_disabled">
+                               <source>Redirect is not activated!</source>
+                       </trans-unit>
+                       <trans-unit id="source_host">
+                               <source>Source Host</source>
+                       </trans-unit>
+                       <trans-unit id="source_path">
+                               <source>Source Path</source>
+                       </trans-unit>
+                       <trans-unit id="source_path.placeholder">
+                               <source>/my-path/</source>
+                       </trans-unit>
+                       <trans-unit id="destination">
+                               <source>Destination</source>
+                       </trans-unit>
+                       <trans-unit id="destination_status_code">
+                               <source>Status Code</source>
+                       </trans-unit>
+                       <trans-unit id="hits">
+                               <source>Hits</source>
+                       </trans-unit>
+                       <trans-unit id="hit_text">
+                               <source>%s hit</source>
+                       </trans-unit>
+                       <trans-unit id="hits_text">
+                               <source>%s hits</source>
+                       </trans-unit>
+                       <trans-unit id="hit_reset">
+                               <source>Reset Hit Counter (!)</source>
+                       </trans-unit>
+                       <trans-unit id="hit_reset.confirm.title">
+                               <source>Reset the hitcounter of this record?</source>
+                       </trans-unit>
+                       <trans-unit id="hit_reset.confirm.content">
+                               <source>Are you sure you want to reset the hitcounter of this record?</source>
+                       </trans-unit>
+                       <trans-unit id="hit_last">
+                               <source>Last Hit on</source>
+                       </trans-unit>
+                       <trans-unit id="hit_last_never">
+                               <source>Never</source>
+                       </trans-unit>
+               </body>
+       </file>
+</xliff>
diff --git a/typo3/sysext/redirects/Resources/Private/Layouts/RedirectAdministration.html b/typo3/sysext/redirects/Resources/Private/Layouts/RedirectAdministration.html
new file mode 100644 (file)
index 0000000..6072397
--- /dev/null
@@ -0,0 +1,2 @@
+<f:render section="headline" />
+<f:render section="content" />
diff --git a/typo3/sysext/redirects/Resources/Private/Templates/Management/Overview.html b/typo3/sysext/redirects/Resources/Private/Templates/Management/Overview.html
new file mode 100644 (file)
index 0000000..d49f85c
--- /dev/null
@@ -0,0 +1,88 @@
+<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" xmlns:rd="http://typo3.org/ns/TYPO3/CMS/Redirects/ViewHelpers" data-namespace-typo3-fluid="true">
+<f:layout name="RedirectAdministration" />
+
+<f:section name="headline">
+       <h1><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:heading_text"/></h1>
+</f:section>
+
+<f:section name="content">
+       <div class="table-fit">
+               <table class="table table-striped table-hover">
+                       <thead>
+                               <tr>
+                                       <th><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:source_host"/></th>
+                                       <th><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:source_path"/></th>
+                                       <th><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:destination"/></th>
+                                       <th># <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hits"/></th>
+                                       <th><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hit_last"/></th>
+                                       <th></th>
+                               </tr>
+                       </thead>
+                       <tbody>
+                               <f:for each="{redirects}" key="domainName" as="redirectsPerDomain">
+                                       <f:for each="{redirectsPerDomain}" as="groupedRedirects">
+                                               <f:for each="{groupedRedirects}" as="redirectRecords">
+                                                       <f:for each="{redirectRecords}" as="redirect">
+                                                               <tr>
+                                                                       <td>{redirect.source_host}</td>
+                                                                       <td>
+                                                                               <f:if condition="{redirect.disabled} == 1"><span title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:record_disabled')}"><core:icon identifier="overlay-hidden" /></span></f:if>
+                                                                               <f:if condition="{redirect.starttime} != 0 || {redirect.endtime} != 0"><span title="{f:format.date(date: redirect.starttime, format: '%d.%m.%Y %H:%M')} - {f:format.date(date: redirect.endtime, format: '%d.%m.%Y %H:%M')}"><core:icon identifier="overlay-scheduled" /></span></f:if>
+                                                                               <strong><f:link.external uri="{redirect.source_host}{redirect.source_path}" target="_blank">{redirect.source_path}</f:link.external></strong>
+                                                                       </td>
+                                                                       <td><f:link.typolink parameter="{redirect.target}" target="_blank"><f:uri.typolink parameter="{redirect.target}"></f:uri.typolink></f:link.typolink> (<f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:destination_status_code"/>: {redirect.target_statuscode})</td>
+                                                                       <td>
+                                                                               <f:if condition="!{redirect.disable_hitcount}">
+                                                                                               <f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hit{f:if(condition:'{redirect.hitcount} > 1',then:'s')}_text" arguments="{0:redirect.hitcount}"/>
+                                                                                               <f:if condition="{redirect.hitcount} != 0">
+                                                                                               <a class="t3js-modal-trigger"
+                                                                                                  href="{rd:editRecord(command: 'resetcounter', uid: redirect.uid)}"
+                                                                                                  title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hit_reset')}"
+                                                                                                  data-title="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hit_reset.confirm.title')}"
+                                                                                                  data-content="{f:translate(key: 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hit_reset.confirm.content')}"
+                                                                                                  data-button-close-text="{f:translate(key: 'LLL:EXT:lang/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
+                                                                                               <core:icon identifier="actions-edit-restore" /></a>
+                                                                                       </f:if>
+                                                                               </f:if>
+                                                                       <td>
+                                                                               <f:if condition="{redirect.lasthiton}">
+                                                                                       <f:then><f:format.date format="d.m.Y H:i:s">@{redirect.lasthiton}</f:format.date></f:then>
+                                                                                       <f:else><f:translate key="LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf:hit_last_never"/></f:else>
+                                                                               </f:if>
+                                                                       </td>
+                                                                       <td>
+                                                                               <div class="btn-group">
+                                                                                       <a class="btn btn-default"
+                                                                                                href="{rd:editRecord(command: 'edit', uid: redirect.uid)}"
+                                                                                                title="{f:translate(key: 'LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:edit')}">
+                                                                                               <core:icon identifier="actions-open" />
+                                                                                       </a>
+                                                                                       <f:if condition="{redirect.disabled} == 1">
+                                                                                               <f:then>
+                                                                                                       <a class="btn btn-default" href="{rd:editRecord(command: 'unhide', uid: redirect.uid)}" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:unHide')}"><core:icon identifier="actions-edit-unhide" /></a>
+                                                                                               </f:then>
+                                                                                               <f:else>
+                                                                                                       <a class="btn btn-default" href="{rd:editRecord(command: 'hide', uid: redirect.uid)}" title="{f:translate(key:'LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:hide')}"><core:icon identifier="actions-edit-hide" /></a>
+                                                                                               </f:else>
+                                                                                       </f:if>
+                                                                                       <a class="btn btn-default t3js-modal-trigger"
+                                                                                                href="{rd:editRecord(command: 'delete', uid: redirect.uid)}"
+                                                                                                title="{f:translate(key: 'LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:delete')}"
+                                                                                                data-severity="warning"
+                                                                                                data-title="{f:translate(key: 'LLL:EXT:lang/Resources/Private/Language/locallang_alt_doc.xlf:label.confirm.delete_record.title')}"
+                                                                                                data-content="{f:translate(key: 'LLL:EXT:lang/Resources/Private/Language/locallang_alt_doc.xlf:deleteWarning')}"
+                                                                                                data-button-close-text="{f:translate(key: 'LLL:EXT:lang/Resources/Private/Language/locallang_alt_doc.xlf:buttons.confirm.delete_record.no')}">
+                                                                                               <core:icon identifier="actions-delete" />
+                                                                                       </a>
+                                                                               </div>
+                                                                       </td>
+                                                               </tr>
+                                                       </f:for>
+                                               </f:for>
+                                       </f:for>
+                               </f:for>
+                       </tbody>
+               </table>
+       </div>
+</f:section>
+</html>
diff --git a/typo3/sysext/redirects/Resources/Public/Icons/Extension.png b/typo3/sysext/redirects/Resources/Public/Icons/Extension.png
new file mode 100644 (file)
index 0000000..98bb005
Binary files /dev/null and b/typo3/sysext/redirects/Resources/Public/Icons/Extension.png differ
diff --git a/typo3/sysext/redirects/Resources/Public/Icons/repeat_64x64.png b/typo3/sysext/redirects/Resources/Public/Icons/repeat_64x64.png
new file mode 100644 (file)
index 0000000..98bb005
Binary files /dev/null and b/typo3/sysext/redirects/Resources/Public/Icons/repeat_64x64.png differ
diff --git a/typo3/sysext/redirects/Tests/Unit/FormDataProvider/ValuePickerItemDataProviderTest.php b/typo3/sysext/redirects/Tests/Unit/FormDataProvider/ValuePickerItemDataProviderTest.php
new file mode 100644 (file)
index 0000000..b76976a
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Tests\Unit\FormDataProvider;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Doctrine\DBAL\Driver\Statement;
+use Prophecy\Prophecy\ObjectProphecy;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\QueryBuilder;
+use TYPO3\CMS\Core\Database\Query\Restriction\QueryRestrictionContainerInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class ValuePickerItemDataProviderTest extends UnitTestCase
+{
+    protected $sysRedirectResultSet = [
+        'tableName' => 'sys_redirect',
+        'processedTca' => [
+            'columns' => [
+                'source_host' => [
+                    'config' => [
+                        'valuePicker' => [
+                            'items' => []
+                        ]
+                    ]
+                ]
+            ]
+        ]
+    ];
+
+    /**
+     * @test
+     */
+    public function addDataDoesNothingIfNoRedirectDataGiven()
+    {
+        $result = [
+            'tableName' => 'tt_content',
+        ];
+
+        $valuePickerItemDataProvider = new ValuePickerItemDataProvider();
+        $actualResult = $valuePickerItemDataProvider->addData($result);
+        self::assertSame($result, $actualResult);
+    }
+
+    /**
+     * @test
+     */
+    public function addDataAddsDomainNameAsKeyAndValueToRedirectValuePicker()
+    {
+        $statementProphecy = $this->setUpDatabase();
+        $statementProphecy->fetchAll()->willReturn(
+            [
+                ['domainName' => 'foo.test'],
+                ['domainName' => 'bar.test'],
+            ]
+        );
+        $valuePickerItemDataProvider = new ValuePickerItemDataProvider();
+        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
+        $expected = $this->sysRedirectResultSet;
+        $expected['processedTca']['columns']['source_host']['config']['valuePicker']['items'] = [
+            ['foo.test', 'foo.test'],
+            ['bar.test', 'bar.test'],
+        ];
+        self::assertSame($expected, $actualResult);
+    }
+
+    /**
+     * @test
+     */
+    public function addDataDoesNotChangeResultSetIfNoSysDomainsAreFound()
+    {
+        $statementProphecy = $this->setUpDatabase();
+        $statementProphecy->fetchAll()->willReturn([]);
+        $valuePickerItemDataProvider = new ValuePickerItemDataProvider();
+        $actualResult = $valuePickerItemDataProvider->addData($this->sysRedirectResultSet);
+
+        self::assertSame($this->sysRedirectResultSet, $actualResult);
+    }
+
+    /**
+     * @return \Doctrine\DBAL\Driver\Statement|\Prophecy\Prophecy\ObjectProphecy
+     */
+    private function setUpDatabase(): ObjectProphecy
+    {
+        $queryBuilderProphecy = $this->prophesize(QueryBuilder::class);
+        $connectionPoolProphecy = $this->prophesize(ConnectionPool::class);
+        $connectionPoolProphecy->getQueryBuilderForTable('sys_domain')->willReturn($queryBuilderProphecy->reveal());
+        $queryRestrictionContainerProphecy = $this->prophesize(QueryRestrictionContainerInterface::class);
+        $queryBuilderProphecy->getRestrictions()->willReturn($queryRestrictionContainerProphecy->reveal());
+        $queryBuilderProphecy->select('domainName')->willReturn($queryBuilderProphecy->reveal());
+        $queryBuilderProphecy->from('sys_domain')->willReturn($queryBuilderProphecy->reveal());
+        $statementProphecy = $this->prophesize(Statement::class);
+        $queryBuilderProphecy->execute()->willReturn($statementProphecy->reveal());
+        GeneralUtility::addInstance(ConnectionPool::class, $connectionPoolProphecy->reveal());
+        return $statementProphecy;
+    }
+}
diff --git a/typo3/sysext/redirects/Tests/Unit/Service/RedirectServiceTest.php b/typo3/sysext/redirects/Tests/Unit/Service/RedirectServiceTest.php
new file mode 100644 (file)
index 0000000..48a58d3
--- /dev/null
@@ -0,0 +1,345 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Redirects\Tests\Unit\Service;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Prophecy\Argument;
+use Prophecy\Prophecy\ObjectProphecy;
+use Psr\Log\LoggerInterface;
+use TYPO3\CMS\Core\Http\Uri;
+use TYPO3\CMS\Core\LinkHandling\LinkService;
+use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
+use TYPO3\CMS\Core\Resource\File;
+use TYPO3\CMS\Core\Resource\Folder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Redirects\Service\RedirectCacheService;
+use TYPO3\CMS\Redirects\Service\RedirectService;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class RedirectServiceTest extends UnitTestCase
+{
+    /**
+     * @var RedirectCacheService|ObjectProphecy
+     */
+    protected $redirectCacheServiceProphecy;
+
+    /**
+     * @var RedirectService
+     */
+    protected $redirectService;
+
+    protected $singletonInstances = [];
+
+    protected function setUp()
+    {
+        parent::setUp();
+        $this->singletonInstances = GeneralUtility::getSingletonInstances();
+        $loggerProphecy = $this->prophesize(LoggerInterface::class);
+        $this->redirectCacheServiceProphecy = $this->prophesize(RedirectCacheService::class);
+        $this->redirectService = new RedirectService();
+        $this->redirectService->setLogger($loggerProphecy->reveal());
+    }
+
+    /**
+     * @test
+     */
+    public function matchRedirectReturnsNullIfNoRedirectsExist()
+    {
+        $this->redirectCacheServiceProphecy->getRedirects()->willReturn([]);
+        GeneralUtility::addInstance(RedirectCacheService::class, $this->redirectCacheServiceProphecy->reveal());
+
+        $result = $this->redirectService->matchRedirect('example.com', 'foo');
+
+        self::assertNull($result);
+    }
+
+    /**
+     * @test
+     */
+    public function matchRedirectReturnsRedirectOnFlatMatch()
+    {
+        $row = [
+            'target' => 'https://example.com',
+            'force_https' => '0',
+            'keep_query_parameters' => '0',
+            'target_statuscode' => '307'
+        ];
+        $this->redirectCacheServiceProphecy->getRedirects()->willReturn(
+            [
+                'example.com' => [
+                    'flat' => [
+                        'foo/' => [
+                            1 => $row,
+                        ],
+                    ],
+                ],
+            ]
+        );
+        GeneralUtility::addInstance(RedirectCacheService::class, $this->redirectCacheServiceProphecy->reveal());
+
+        $result = $this->redirectService->matchRedirect('example.com', 'foo');
+
+        self::assertSame($row, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function matchRedirectReturnsRedirectSpecificToDomainOnFlatMatchIfSpecificAndNonSpecificExist()
+    {
+        $row1 = [
+            'target' => 'https://example.com',
+            'force_https' => '0',
+            'keep_query_parameters' => '0',
+            'target_statuscode' => '307'
+        ];
+        $row2 = [
+            'target' => 'https://example.net',
+            'force_https' => '0',
+            'keep_query_parameters' => '0',
+            'target_statuscode' => '307'
+        ];
+        $this->redirectCacheServiceProphecy->getRedirects()->willReturn(
+            [
+                'example.com' => [
+                    'flat' => [
+                        'foo/' => [
+                            1 => $row1,
+                        ],
+                    ],
+                ],
+                '*' => [
+                    'flat' => [
+                        'foo/' => [
+                            2 => $row2,
+                        ],
+                    ],
+                ],
+            ]
+        );
+        GeneralUtility::addInstance(RedirectCacheService::class, $this->redirectCacheServiceProphecy->reveal());
+
+        $result = $this->redirectService->matchRedirect('example.com', 'foo');
+
+        self::assertSame($row1, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function matchRedirectReturnsRedirectOnRegexMatch()
+    {
+        $row = [
+            'target' => 'https://example.com',
+            'force_https' => '0',
+            'keep_query_parameters' => '0',
+            'target_statuscode' => '307'
+        ];
+        $this->redirectCacheServiceProphecy->getRedirects()->willReturn(
+            [
+                'example.com' => [
+                    'regexp' => [
+                        '/f.*?/' => [
+                            1 => $row,
+                        ],
+                    ],
+                ],
+            ]
+        );
+        GeneralUtility::addInstance(RedirectCacheService::class, $this->redirectCacheServiceProphecy->reveal());
+
+        $result = $this->redirectService->matchRedirect('example.com', 'foo');
+
+        self::assertSame($row, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function matchRedirectReturnsOnlyActiveRedirects()
+    {
+        $row1 = [
+            'target' => 'https://example.com',
+            'force_https' => '0',
+            'keep_query_parameters' => '0',
+            'target_statuscode' => '307',
+            'disabled' => '1'
+        ];
+        $row2 = [
+            'target' => 'https://example.net',
+            'force_https' => '0',
+            'keep_query_parameters' => '0',
+            'target_statuscode' => '307',
+            'disabled' => '0'
+        ];
+        $this->redirectCacheServiceProphecy->getRedirects()->willReturn(
+            [
+                'example.com' => [
+                    'flat' => [
+                        'foo/' => [
+                            1 => $row1,
+                            2 => $row2
+                        ],
+                    ],
+                ],
+            ]
+        );
+        GeneralUtility::addInstance(RedirectCacheService::class, $this->redirectCacheServiceProphecy->reveal());
+
+        $result = $this->redirectService->matchRedirect('example.com', 'foo');
+
+        self::assertSame($row2, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getTargetUrlReturnsNullIfUrlCouldNotBeResolved()
+    {
+        $linkServiceProphecy = $this->prophesize(LinkService::class);
+        $linkServiceProphecy->resolve(Argument::any())->willThrow(new InvalidPathException('', 1516531195));
+        GeneralUtility::setSingletonInstance(LinkService::class, $linkServiceProphecy->reveal());
+
+        $result = $this->redirectService->getTargetUrl(['target' => 'invalid'], []);
+
+        self::assertNull($result);
+    }
+
+    /**
+     * @test
+     */
+    public function getTargetUrlReturnsUrlForTypeUrl()
+    {
+        $linkServiceProphecy = $this->prophesize(LinkService::class);
+        $redirectTargetMatch = [
+            'target' => 'https://example.com',
+        ];
+        $linkDetails = [
+            'type' => LinkService::TYPE_URL,
+            'url' => 'https://example.com/'
+        ];
+        $linkServiceProphecy->resolve($redirectTargetMatch['target'])->willReturn($linkDetails);
+        GeneralUtility::setSingletonInstance(LinkService::class, $linkServiceProphecy->reveal());
+
+        $result = $this->redirectService->getTargetUrl($redirectTargetMatch, []);
+
+        $uri = new Uri('https://example.com/');
+        self::assertEquals($uri, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getTargetUrlReturnsUrlForTypeFile()
+    {
+        $linkServiceProphecy = $this->prophesize(LinkService::class);
+        $fileProphecy = $this->prophesize(File::class);
+        $fileProphecy->getPublicUrl()->willReturn('https://example.com/file.txt');
+        $redirectTargetMatch = [
+            'target' => 'https://example.com',
+        ];
+        $linkDetails = [
+            'type' => LinkService::TYPE_FILE,
+            'file' => $fileProphecy->reveal()
+        ];
+        $linkServiceProphecy->resolve($redirectTargetMatch['target'])->willReturn($linkDetails);
+        GeneralUtility::setSingletonInstance(LinkService::class, $linkServiceProphecy->reveal());
+
+        $result = $this->redirectService->getTargetUrl($redirectTargetMatch, []);
+
+        $uri = new Uri('https://example.com/file.txt');
+        self::assertEquals($uri, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getTargetUrlReturnsUrlForTypeFolder()
+    {
+        $linkServiceProphecy = $this->prophesize(LinkService::class);
+        $folderProphecy = $this->prophesize(Folder::class);
+        $folderProphecy->getPublicUrl()->willReturn('https://example.com/folder/');
+        $redirectTargetMatch = [
+            'target' => 'https://example.com',
+        ];
+        $folder = $folderProphecy->reveal();
+        $linkDetails = [
+            'type' => LinkService::TYPE_FOLDER,
+            'folder' => $folder
+        ];
+        $linkServiceProphecy->resolve($redirectTargetMatch['target'])->willReturn($linkDetails);
+        GeneralUtility::setSingletonInstance(LinkService::class, $linkServiceProphecy->reveal());
+
+        $result = $this->redirectService->getTargetUrl($redirectTargetMatch, []);
+
+        $uri = new Uri('https://example.com/folder/');
+        self::assertEquals($uri, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getTargetUrlRespectsForceHttps()
+    {
+        $linkServiceProphecy = $this->prophesize(LinkService::class);
+        $redirectTargetMatch = [
+            'target' => 'https://example.com',
+            'force_https' => '1'
+        ];
+        $linkDetails = [
+            'type' => LinkService::TYPE_URL,
+            'url' => 'http://example.com'
+        ];
+        $linkServiceProphecy->resolve($redirectTargetMatch['target'])->willReturn($linkDetails);
+        GeneralUtility::setSingletonInstance(LinkService::class, $linkServiceProphecy->reveal());
+
+        $result = $this->redirectService->getTargetUrl($redirectTargetMatch, []);
+
+        $uri = new Uri('https://example.com');
+        self::assertEquals($uri, $result);
+    }
+
+    /**
+     * @test
+     */
+    public function getTargetUrlAddsExistingQueryParams()
+    {
+        $linkServiceProphecy = $this->prophesize(LinkService::class);
+        $redirectTargetMatch = [
+            'target' => 'https://example.com',
+            'keep_query_parameters' => '1'
+        ];
+        $linkDetails = [
+            'type' => LinkService::TYPE_URL,
+            'url' => 'https://example.com/?foo=1&bar=2'
+        ];
+        $linkServiceProphecy->resolve($redirectTargetMatch['target'])->willReturn($linkDetails);
+        GeneralUtility::setSingletonInstance(LinkService::class, $linkServiceProphecy->reveal());
+
+        $result = $this->redirectService->getTargetUrl($redirectTargetMatch, ['bar' => 3, 'baz' => 4]);
+
+        $uri = new Uri('https://example.com/?bar=2&baz=4&foo=1');
+        self::assertEquals($uri, $result);
+    }
+
+    /**
+     * Tear down
+     */
+    public function tearDown()
+    {
+        GeneralUtility::resetSingletonInstances($this->singletonInstances);
+        parent::tearDown();
+    }
+}
diff --git a/typo3/sysext/redirects/composer.json b/typo3/sysext/redirects/composer.json
new file mode 100644 (file)
index 0000000..fef365a
--- /dev/null
@@ -0,0 +1,36 @@
+{
+       "name": "typo3/cms-redirects",
+       "type": "typo3-cms-framework",
+       "description": "Custom redirects in TYPO3.",
+       "homepage": "https://typo3.org",
+       "license": ["GPL-2.0-or-later"],
+       "authors": [{
+               "name": "TYPO3 Core Team",
+               "email": "typo3cms@typo3.org",
+               "role": "Developer"
+       }],
+       "require": {
+               "typo3/cms-core": "9.1.*@dev",
+               "typo3/cms-backend": "9.1.*@dev",
+               "typo3fluid/fluid": "^2.3"
+       },
+       "conflict": {
+               "typo3/cms": "*"
+       },
+       "replace": {
+               "redirects": "*"
+       },
+       "extra": {
+               "branch-alias": {
+                       "dev-master": "9.1.x-dev"
+               },
+               "typo3/cms": {
+                       "extension-key": "redirects"
+               }
+       },
+       "autoload": {
+               "psr-4": {
+                       "TYPO3\\CMS\\Redirects\\": "Classes/"
+               }
+       }
+}
diff --git a/typo3/sysext/redirects/ext_emconf.php b/typo3/sysext/redirects/ext_emconf.php
new file mode 100644 (file)
index 0000000..7a81d6c
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+$EM_CONF[$_EXTKEY] = [
+    'title' => 'Redirects',
+    'description' => 'Manage redirects for your TYPO3-based website.',
+    'category' => 'fe',
+    'author' => 'TYPO3 Core Team',
+    'author_email' => 'typo3cms@typo3.org',
+    'author_company' => '',
+    'state' => 'stable',
+    'uploadfolder' => 0,
+    'createDirs' => '',
+    'clearCacheOnLoad' => 0,
+    'version' => '9.1.0',
+    'constraints' => [
+        'depends' => [
+            'typo3' => '9.1.0-9.1.0'
+        ],
+        'conflicts' => [],
+        'suggests' => [],
+    ],
+];
diff --git a/typo3/sysext/redirects/ext_localconf.php b/typo3/sysext/redirects/ext_localconf.php
new file mode 100644 (file)
index 0000000..4c91813
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+defined('TYPO3_MODE') or die();
+
+// Register hook into the frontend to check for a possible redirect
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tslib/index_ts.php']['preprocessRequest']['redirects'] = \TYPO3\CMS\Redirects\Http\RedirectHandler::class . '->handle';
+
+// Rebuild cache in DataHandler on changing / inserting / adding redirect records
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc']['redirects'] = \TYPO3\CMS\Redirects\Hooks\DataHandlerCacheFlushingHook::class . '->rebuildRedirectCacheIfNecessary';
+
+// Inject sys_domains into valuepicker form
+$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['formDataGroup']['tcaDatabaseRecord']
+[\TYPO3\CMS\Redirects\FormDataProvider\ValuePickerItemDataProvider::class] = [
+    'depends' => [
+        \TYPO3\CMS\Backend\Form\FormDataProvider\TcaInputPlaceholders::class,
+    ],
+];
+
+// Add validation call for form field source_host and source_path
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][\TYPO3\CMS\Redirects\Evaluation\SourcePath::class] = '';
+$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['tce']['formevals'][\TYPO3\CMS\Redirects\Evaluation\SourceHost::class] = '';
diff --git a/typo3/sysext/redirects/ext_tables.php b/typo3/sysext/redirects/ext_tables.php
new file mode 100644 (file)
index 0000000..a6debe7
--- /dev/null
@@ -0,0 +1,16 @@
+<?php
+defined('TYPO3_MODE') or die();
+
+\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addModule(
+    'site',
+    'redirects',
+    '',
+    '',
+    [
+        'routeTarget' => \TYPO3\CMS\Redirects\Controller\ManagementController::class . '::handleRequest',
+        'access' => 'group,user',
+        'name' => 'site_redirects',
+        'icon' => 'EXT:redirects/Resources/Public/Icons/repeat_64x64.png',
+        'labels' => 'LLL:EXT:redirects/Resources/Private/Language/locallang_module_redirect.xlf'
+    ]
+);
diff --git a/typo3/sysext/redirects/ext_tables.sql b/typo3/sysext/redirects/ext_tables.sql
new file mode 100644 (file)
index 0000000..f682f4d
--- /dev/null
@@ -0,0 +1,32 @@
+#
+# Table structure for table 'sys_redirect'
+#
+CREATE TABLE sys_redirect (
+       uid int(11) unsigned NOT NULL auto_increment,
+       pid int(11) DEFAULT '0' NOT NULL,
+
+       source_host varchar(255) DEFAULT '' NOT NULL,
+       source_path varchar(255) DEFAULT '' NOT NULL,
+       is_regexp tinyint(1) unsigned DEFAULT '0' NOT NULL,
+
+       force_https tinyint(1) unsigned DEFAULT '0' NOT NULL,
+       keep_query_parameters tinyint(1) unsigned DEFAULT '0' NOT NULL,
+       target varchar(255) DEFAULT '' NOT NULL,
+       target_statuscode int(11) DEFAULT '0' NOT NULL,
+
+       hitcount int(11) DEFAULT '0' NOT NULL,
+       lasthiton int(11) DEFAULT '0' NOT NULL,
+       disable_hitcount tinyint(1) unsigned DEFAULT '0' NOT NULL,
+
+       createdby int(11) unsigned DEFAULT '0' NOT NULL,
+       createdon int(11) unsigned DEFAULT '0' NOT NULL,
+       updatedon int(11) unsigned DEFAULT '0' NOT NULL,
+       deleted tinyint(4) unsigned DEFAULT '0' NOT NULL,
+       disabled tinyint(4) unsigned DEFAULT '0' NOT NULL,
+       starttime int(11) unsigned DEFAULT '0' NOT NULL,
+       endtime int(11) unsigned DEFAULT '0' NOT NULL,
+
+       PRIMARY KEY (uid),
+       KEY parent (pid),
+       KEY index_source (source_host,source_path)
+);