From 4039a4a8df007255d12ec72409a7ad520f20e306 Mon Sep 17 00:00:00 2001 From: Daniel Goerz Date: Sat, 29 Jun 2019 15:28:39 +0200 Subject: [PATCH] [TASK] Open Source Release --- .../PreviewUserAuthentication.php | 80 +++++++++ Classes/Controller/PreviewController.php | 105 +++++++++++ Classes/Http/Middleware/Preview.php | 153 ++++++++++++++++ Classes/Preview/PreviewUriBuilder.php | 77 ++++++++ Classes/Preview/SitePreview.php | 169 ++++++++++++++++++ Classes/SiteWrapper.php | 59 ++++++ Configuration/RequestMiddlewares.php | 16 ++ Readme.md | 35 ++++ .../Private/Language/locallang_module.xlf | 17 ++ .../Private/Templates/Preview/Index.html | 93 ++++++++++ Resources/Public/Icons/Extension.svg | 1 + composer.json | 21 +++ ext_emconf.php | 20 +++ ext_tables.php | 16 ++ ext_tables.sql | 10 ++ 15 files changed, 872 insertions(+) create mode 100644 Classes/Authentication/PreviewUserAuthentication.php create mode 100644 Classes/Controller/PreviewController.php create mode 100644 Classes/Http/Middleware/Preview.php create mode 100644 Classes/Preview/PreviewUriBuilder.php create mode 100644 Classes/Preview/SitePreview.php create mode 100644 Classes/SiteWrapper.php create mode 100644 Configuration/RequestMiddlewares.php create mode 100644 Readme.md create mode 100644 Resources/Private/Language/locallang_module.xlf create mode 100644 Resources/Private/Templates/Preview/Index.html create mode 100644 Resources/Public/Icons/Extension.svg create mode 100644 composer.json create mode 100644 ext_emconf.php create mode 100644 ext_tables.php create mode 100644 ext_tables.sql diff --git a/Classes/Authentication/PreviewUserAuthentication.php b/Classes/Authentication/PreviewUserAuthentication.php new file mode 100644 index 0000000..dcaee8b --- /dev/null +++ b/Classes/Authentication/PreviewUserAuthentication.php @@ -0,0 +1,80 @@ +name = PreviewUriBuilder::PARAMETER_NAME; + $this->siteLanguage = $siteLanguage ?? $GLOBALS['TYPO3_REQUEST']->getAttribute('language', null); + } + + /** + * A preview user has read-only permissions, always. + * + * @param int $perms + * @return string + */ + public function getPagePermsClause($perms) + { + if ($perms === Permission::PAGE_SHOW) { + return '1=1'; + } + return '0=1'; + } + + /** + * Has read permissions on the whole workspace, but nothing else + * + * @param array $row + * @return int + */ + public function calcPerms($row) + { + return Permission::PAGE_SHOW; + } + + /** + * This user is always allowed to see the current language + * + * @param int $langValue + * @return bool + */ + public function checkLanguageAccess($langValue) + { + if ($this->siteLanguage === null) { + return false; + } + return (int)$langValue === $this->siteLanguage->getLanguageId(); + } +} diff --git a/Classes/Controller/PreviewController.php b/Classes/Controller/PreviewController.php new file mode 100644 index 0000000..57557ec --- /dev/null +++ b/Classes/Controller/PreviewController.php @@ -0,0 +1,105 @@ +moduleTemplate = GeneralUtility::makeInstance(ModuleTemplate::class); + $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class); + $this->initializeView('index'); + } + + /** + * @param string $templateName + */ + protected function initializeView(string $templateName) + { + $this->view = GeneralUtility::makeInstance(StandaloneView::class); + $this->view->setTemplate($templateName); + $this->view->setTemplateRootPaths(['EXT:authorized_preview/Resources/Private/Templates/Preview']); + } + + /** + * @param ServerRequestInterface $request + * @return ResponseInterface the response with the content + */ + public function indexAction(ServerRequestInterface $request): ResponseInterface + { + $this->view->assign('sites', $this->getAllSites()); + + $sitePreview = SitePreview::createFromRequest($request); + if ($sitePreview->isValid()) { + $this->view->assign('sitePreview', $sitePreview); + } + + $this->moduleTemplate->setContent($this->view->render()); + return new HtmlResponse($this->moduleTemplate->renderContent()); + } + + /** + * @return Site[] + */ + protected function getAllSites(): array + { + $siteFinder = GeneralUtility::makeInstance(SiteFinder::class); + $sites = []; + foreach ($siteFinder->getAllSites() as $site) { + if (!($site instanceof Site)) { + continue; + } + $sites[] = GeneralUtility::makeInstance(SiteWrapper::class, $site); + } + return $sites; + } +} diff --git a/Classes/Http/Middleware/Preview.php b/Classes/Http/Middleware/Preview.php new file mode 100644 index 0000000..2c4a128 --- /dev/null +++ b/Classes/Http/Middleware/Preview.php @@ -0,0 +1,153 @@ +findHashInRequest($request); + if (empty($hash)) { + return $handler->handle($request); + } + + $language = $request->getAttribute('language', null); + if ($language instanceof SiteLanguage && $language->isEnabled()) { + return $handler->handle($request); + } + + + $context = GeneralUtility::makeInstance(Context::class); + if (!$this->verifyHash($hash, $context, $language)) { + return $handler->handle($request); + } + + // If the GET parameter PreviewUriBuilder::PARAMETER_NAME is set, then a cookie is set for the next request + if ($request->getQueryParams()[PreviewUriBuilder::PARAMETER_NAME] ?? false) { + $this->setCookie($hash, $request->getAttribute('normalizedParams')); + } + $previewUser = $this->initializePreviewUser(); + if ($previewUser) { + $GLOBALS['BE_USER'] = $previewUser; + $this->setBackendUserAspect($context, $previewUser); + } else { + return $handler->handle($request); + } + + return $handler->handle($request); + } + + /** + * Looks for the PreviewUriBuilder::PARAMETER_NAME in the QueryParams and Cookies + * + * @param ServerRequestInterface $request + * @return string + */ + protected function findHashInRequest(ServerRequestInterface $request): string + { + return $request->getQueryParams()[PreviewUriBuilder::PARAMETER_NAME] ?? $request->getCookieParams()[PreviewUriBuilder::PARAMETER_NAME] ?? ''; + } + + /** + * Sets a cookie + * + * @param string $inputCode + * @param NormalizedParams $normalizedParams + */ + protected function setCookie(string $inputCode, NormalizedParams $normalizedParams) + { + setcookie(PreviewUriBuilder::PARAMETER_NAME, $inputCode, 0, $normalizedParams->getSitePath(), '', true, true); + } + + /** + * Creates a preview user and sets the current page ID (for accessing the page) + * + * @return PreviewUserAuthentication + */ + protected function initializePreviewUser() + { + $previewUser = GeneralUtility::makeInstance(PreviewUserAuthentication::class); + $previewUser->setWebmounts([$GLOBALS['TSFE']->id]); + return $previewUser; + } + + /** + * Register the backend user as aspect + * + * @param Context $context + * @param BackendUserAuthentication $user + */ + protected function setBackendUserAspect(Context $context, BackendUserAuthentication $user = null) + { + $context->setAspect('backend.user', GeneralUtility::makeInstance(UserAspect::class, $user)); + } + + /** + * Looks for the hash in the tx_authorized_preview + * Must not be expired yet. + * + * @param string $hash + * @param Context $context + * @param SiteLanguage $language + * @return bool + */ + protected function verifyHash(string $hash, Context $context, SiteLanguage $language): bool + { + $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) + ->getQueryBuilderForTable('tx_authorized_preview'); + $row = $queryBuilder + ->select('*') + ->from('tx_authorized_preview') + ->where( + $queryBuilder->expr()->eq( + 'hash', + $queryBuilder->createNamedParameter($hash) + ), + $queryBuilder->expr()->gt( + 'endtime', + $queryBuilder->createNamedParameter($context->getPropertyFromAspect('date', 'timestamp'), \PDO::PARAM_INT) + ) + ) + ->setMaxResults(1) + ->execute() + ->fetch(); + + if (empty($row)) { + return false; + } + + $config = json_decode($row['config'], true); + return (int)$config['languageId'] === $language->getLanguageId(); + } +} diff --git a/Classes/Preview/PreviewUriBuilder.php b/Classes/Preview/PreviewUriBuilder.php new file mode 100644 index 0000000..1660067 --- /dev/null +++ b/Classes/Preview/PreviewUriBuilder.php @@ -0,0 +1,77 @@ +sitePreview = $sitePreview; + $this->hash = md5(uniqid(microtime(), true)); + } + + /** + * @return string + */ + public function generatePreviewUrl(): string + { + $this->storeInDatabase(); + return rtrim((string)$this->sitePreview->getLanguage()->getBase(), '/') . '/?' . self::PARAMETER_NAME . '=' . $this->hash; + } + + /** + * @return void + */ + protected function storeInDatabase() + { + $context = GeneralUtility::makeInstance(Context::class); + GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('tx_authorized_preview') + ->insert( + 'tx_authorized_preview', + [ + 'hash' => $this->hash, + 'tstamp' => $context->getPropertyFromAspect('date', 'timestamp'), + 'endtime' => $context->getPropertyFromAspect('date', 'timestamp') + $this->sitePreview->getLifetime(), + 'config' => json_encode([ + 'languageId' => $this->sitePreview->getLanguage()->getLanguageId(), + ]) + ] + ); + } +} diff --git a/Classes/Preview/SitePreview.php b/Classes/Preview/SitePreview.php new file mode 100644 index 0000000..6d2544e --- /dev/null +++ b/Classes/Preview/SitePreview.php @@ -0,0 +1,169 @@ +getSiteByIdentifier($identifier); + if ($languageId > -1) { + $this->languageId = $languageId; + $this->site = $site; + $this->calculateLifetime($lifetime); + $this->generatePreviewUrl(); + $this->valid = true; + } + } catch (SiteNotFoundException $e) { + $this->valid = false; + } + } + + /** + * @param ServerRequestInterface $request + * @return SitePreview + */ + public static function createFromRequest(ServerRequestInterface $request): SitePreview + { + $languageId = (int)$request->getQueryParams()['languageId'] ?? $request->getParsedBody()['languageId'] ?? -1; + $identifier = $request->getQueryParams()['identifier'] ?? $request->getParsedBody()['identifier'] ?? ''; + $lifetime = $request->getQueryParams()['lifetime'] ?? $request->getParsedBody()['lifetime'] ?? []; + return new self($languageId, $identifier, $lifetime); + } + + /** + * @return SiteLanguage + */ + public function getLanguage(): SiteLanguage + { + return $this->site->getLanguageById($this->languageId); + } + + /** + * @return Site + */ + public function getSite(): Site + { + return $this->site; + } + + /** + * @return int + */ + public function getLifetime(): int + { + return $this->lifeTime; + } + + /** + * @return string + */ + public function getPreviewUrl(): string + { + if (empty($this->previewUrl)) { + $this->generatePreviewUrl(); + } + return $this->previewUrl; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->valid; + } + + /** + * @return void + */ + protected function generatePreviewUrl() + { + $this->previewUrl = GeneralUtility::makeInstance(PreviewUriBuilder::class, $this)->generatePreviewUrl(); + } + + /** + * @param array $lifetime + * @return void + */ + protected function calculateLifetime(array $lifetime) + { + if (empty($lifetime)) { + return; + } + + if (empty($lifetime['type']) || empty($lifetime['amount'])) { + return; + } + + if (MathUtility::canBeInterpretedAsInteger($lifetime['amount']) === false) { + return; + } + + switch ($lifetime['type']) { + case 'day': + $this->lifeTime = 60 * 60 * 24 * (int)$lifetime['amount']; + break; + case 'week': + $this->lifeTime = 60 * 60 * 24 * 7 * (int)$lifetime['amount']; + break; + default: + return; + } + } +} diff --git a/Classes/SiteWrapper.php b/Classes/SiteWrapper.php new file mode 100644 index 0000000..a37d6ba --- /dev/null +++ b/Classes/SiteWrapper.php @@ -0,0 +1,59 @@ +site = $site; + } + + /** + * @return SiteLanguage[] + */ + public function getDisabledLanguages(): array + { + $languages = []; + foreach ($this->site->getAllLanguages() as $languageId => $language) { + if ($language->enabled() === false) { + $languages[] = $language; + } + } + return $languages; + } + + /** + * @return Site + */ + public function getSite(): Site + { + return $this->site; + } +} diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..200068e --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,16 @@ + [ + 'tx_authorized_preview/preview' => [ + 'target' => B13\AuthorizedPreview\Http\Middleware\Preview::class, + 'after' => [ + 'typo3/cms-frontend/site' + ], + 'before' => [ + 'typo3/cms-frontend/page-resolver', + 'typo3/cms-frontend/static-route-resolver', + 'typo3/cms-redirects/redirecthandler' + ] + ] + ] +]; diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..cbbdf4b --- /dev/null +++ b/Readme.md @@ -0,0 +1,35 @@ +# EXT: authorized_preview + +authorized_preview is an extension for TYPO3. It adds the functionality to preview disabled languages +through generated preview URLs and without the need for a backend login. + +## Requirements + +* TYPO3 9 LTS +* Site Configuration(s) + +## Installation and Setup +Install the extension via your preferred way. The extension will add one database table to the database. +No further setup is required. The extension works out of the box. + +## What is does +The extension adds a backend module called "Preview". The module lists all disabled languages +(show in frontend = 0) for each Site. For each disabled language a lifetime can be configured and a +preview URL can be generated that can then be send to colleagues (e.g. for proofreading). + +Within their lifetime the preview URLs enable access to a disabled language without a backend login. +For any other website visitor the disabled languages is still not accessible. + +## How it works +The Preview URLs are pointing to the configured bases of the languages. They also contain a +specific GET parameter. + +This parameter is stored in the Database alongside the configured lifetime for the URL. As long +ans this lifetime is not expired a PSR-15 middleware recognizes the GET parameter and +grants access to the language. The parameter is stored in a Cookie so that the GET parameter is +not needed on every request. + +--- + + +_Made by [b13](https://b13.com) with ♥_ \ No newline at end of file diff --git a/Resources/Private/Language/locallang_module.xlf b/Resources/Private/Language/locallang_module.xlf new file mode 100644 index 0000000..fdbab72 --- /dev/null +++ b/Resources/Private/Language/locallang_module.xlf @@ -0,0 +1,17 @@ + + + +
+ + + Preview URLs + + + Generate Preview URLs for accessing disabled languages without a backend login. + + + Preview + + + + diff --git a/Resources/Private/Templates/Preview/Index.html b/Resources/Private/Templates/Preview/Index.html new file mode 100644 index 0000000..1337b0f --- /dev/null +++ b/Resources/Private/Templates/Preview/Index.html @@ -0,0 +1,93 @@ + + + + +

{sitePreview.previewUrl}

+

Copy the above URL. It allows viewing the disabled language without a backend login. The URL expires at + .

+
+
+ +

Generate a preview URL to a disabled language

+ +
+ +
+ +
+
+ + +
+ +
+
+
+ +
+
+

{disabledLanguage.title}

+ {disabledLanguage.base} +
+
+ +
+
+
+


+
+
+ + +
+

+
+ +
+
+
+
+
+
+
+ +

No disabled Languages

+
+
+
+
+
+
+
+ + diff --git a/Resources/Public/Icons/Extension.svg b/Resources/Public/Icons/Extension.svg new file mode 100644 index 0000000..5c48ba1 --- /dev/null +++ b/Resources/Public/Icons/Extension.svg @@ -0,0 +1 @@ + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..861e86c --- /dev/null +++ b/composer.json @@ -0,0 +1,21 @@ +{ + "name": "b13/authorized-preview", + "type": "typo3-cms-extension", + "description": "Generate URLs to preview hidden languages without a backend login", + "require": { + "typo3/cms-core": "^9.5" + }, + "replace": { + "authorized_preview": "self.version" + }, + "extra": { + "typo3/cms": { + "extension-key": "authorized_preview" + } + }, + "autoload": { + "psr-4": { + "B13\\AuthorizedPreview\\": "Classes/" + } + } +} diff --git a/ext_emconf.php b/ext_emconf.php new file mode 100644 index 0000000..683dd88 --- /dev/null +++ b/ext_emconf.php @@ -0,0 +1,20 @@ + 'Authorized Previews', + 'description' => 'Generate URLs to preview hidden languages without a backend login', + 'category' => 'be', + 'state' => 'stable', + 'uploadfolder' => 0, + 'clearCacheOnLoad' => 0, + 'author' => 'Daniel Goerz', + 'author_email' => 'daniel.goerz@b13.com', + 'author_company' => 'b13 GmbH', + 'version' => '1.0.0', + 'constraints' => [ + 'depends' => [ + 'typo3' => '9.5.0' + ], + 'conflicts' => [], + 'suggests' => [], + ], +]; diff --git a/ext_tables.php b/ext_tables.php new file mode 100644 index 0000000..f651142 --- /dev/null +++ b/ext_tables.php @@ -0,0 +1,16 @@ + \B13\AuthorizedPreview\Controller\PreviewController::class . '::indexAction', + 'access' => 'group,user', + 'name' => 'site_previews', + 'icon' => 'EXT:authorized_preview/Resources/Public/Icons/Extension.svg', + 'labels' => 'LLL:EXT:authorized_preview/Resources/Private/Language/locallang_module.xlf' + ] +); diff --git a/ext_tables.sql b/ext_tables.sql new file mode 100644 index 0000000..d80771a --- /dev/null +++ b/ext_tables.sql @@ -0,0 +1,10 @@ +# +# Table structure for table 'tx_authorized_preview' +# +CREATE TABLE tx_authorized_preview ( + hash varchar(32) DEFAULT '' NOT NULL, + tstamp int(11) DEFAULT '0' NOT NULL, + endtime int(11) DEFAULT '0' NOT NULL, + config text, + PRIMARY KEY (hash) +);