Completed
Push — master ( 4dc794...0f9475 )
by
unknown
15:26
created

WorkspacePreview   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 362
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 156
dl 0
loc 362
rs 9.36
c 1
b 0
f 0
wmc 38

12 Methods

Rating   Name   Duplication   Size   Complexity  
A getPreviewInputCode() 0 3 1
A getPreviewData() 0 20 1
A getPreviewConfigurationFromRequest() 0 20 5
A initializePreviewUser() 0 10 3
A setBackendUserAspect() 0 4 2
A renderPreviewInfo() 0 45 5
A removePreviewParameterFromUrl() 0 3 1
A getWorkspaceTitle() 0 16 2
A getLogoutTemplateMessage() 0 19 3
A getLanguageService() 0 3 2
A setCookie() 0 20 2
B process() 0 64 11
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
namespace TYPO3\CMS\Workspaces\Middleware;
19
20
use Psr\Http\Message\ResponseInterface;
21
use Psr\Http\Message\ServerRequestInterface;
22
use Psr\Http\Server\MiddlewareInterface;
23
use Psr\Http\Server\RequestHandlerInterface;
24
use Symfony\Component\HttpFoundation\Cookie;
25
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
26
use TYPO3\CMS\Core\Context\Context;
27
use TYPO3\CMS\Core\Context\UserAspect;
28
use TYPO3\CMS\Core\Context\WorkspaceAspect;
29
use TYPO3\CMS\Core\Database\ConnectionPool;
30
use TYPO3\CMS\Core\Http\CookieHeaderTrait;
31
use TYPO3\CMS\Core\Http\HtmlResponse;
32
use TYPO3\CMS\Core\Http\NormalizedParams;
33
use TYPO3\CMS\Core\Http\Stream;
34
use TYPO3\CMS\Core\Localization\LanguageService;
35
use TYPO3\CMS\Core\Routing\PageArguments;
36
use TYPO3\CMS\Core\Utility\GeneralUtility;
37
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
38
use TYPO3\CMS\Workspaces\Authentication\PreviewUserAuthentication;
39
40
/**
41
 * Middleware to
42
 * - evaluate ADMCMD_prev as GET parameter or from a cookie
43
 * - initializes the PreviewUser as $GLOBALS['BE_USER']
44
 * - renders a message about a possible workspace previewing currently
45
 *
46
 * @internal
47
 */
48
class WorkspacePreview implements MiddlewareInterface
49
{
50
    use CookieHeaderTrait;
51
52
    /**
53
     * The GET parameter to be used (also the cookie name)
54
     *
55
     * @var string
56
     */
57
    protected $previewKey = 'ADMCMD_prev';
58
59
    /**
60
     * Initializes a possible preview user (by checking for GET/cookie of name "ADMCMD_prev")
61
     *
62
     * The GET parameter "ADMCMD_noBeUser" can be used to preview a live workspace from the backend even if the
63
     * backend user is in a different workspace.
64
     *
65
     * Additionally, if a workspace is previewed, an additional message text is shown.
66
     *
67
     * @param ServerRequestInterface $request
68
     * @param RequestHandlerInterface $handler
69
     * @return ResponseInterface
70
     * @throws \Exception
71
     */
72
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
73
    {
74
        $addInformationAboutDisabledCache = false;
75
        $keyword = $this->getPreviewInputCode($request);
76
        if ($keyword) {
77
            switch ($keyword) {
78
                case 'IGNORE':
79
                    break;
80
                case 'LOGOUT':
81
                    // "log out", and unset the cookie
82
                    $this->setCookie('', $request->getAttribute('normalizedParams'));
83
                    $message = $this->getLogoutTemplateMessage($request->getQueryParams()['returnUrl'] ?? '');
84
                    return new HtmlResponse($message);
85
                default:
86
                    $pageArguments = $request->getAttribute('routing', null);
87
                    // A keyword was found in a query parameter or in a cookie
88
                    // If the keyword is valid, activate a BE User and override any existing BE Users
89
                    $configuration = $this->getPreviewConfigurationFromRequest($request, $keyword);
90
                    if (is_array($configuration) && $configuration['fullWorkspace'] > 0 && $pageArguments instanceof PageArguments) {
91
                        $previewUser = $this->initializePreviewUser(
92
                            (int)$configuration['fullWorkspace'],
93
                            $pageArguments->getPageId()
94
                        );
95
                        if ($previewUser) {
96
                            $GLOBALS['BE_USER'] = $previewUser;
97
                            // Register the preview user as aspect
98
                            $this->setBackendUserAspect(GeneralUtility::makeInstance(Context::class), $previewUser);
0 ignored issues
show
Bug introduced by
It seems like $previewUser can also be of type true; however, parameter $user of TYPO3\CMS\Workspaces\Mid...:setBackendUserAspect() does only seem to accept TYPO3\CMS\Core\Authentic...UserAuthentication|null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

98
                            $this->setBackendUserAspect(GeneralUtility::makeInstance(Context::class), /** @scrutinizer ignore-type */ $previewUser);
Loading history...
99
                        }
100
                    }
101
            }
102
        }
103
104
        // If "ADMCMD_noBeUser" is set, then ensure that there is no workspace preview and no BE User logged in.
105
        // This option is solely used to ensure that a be user can preview the live version of a page in the
106
        // workspace preview module.
107
        if ($request->getQueryParams()['ADMCMD_noBeUser']) {
108
            $GLOBALS['BE_USER'] = null;
109
            // Register the backend user as aspect
110
            $this->setBackendUserAspect(GeneralUtility::makeInstance(Context::class), null);
111
            // Caching is disabled, because otherwise generated URLs could include the ADMCMD_noBeUser parameter
112
            $request = $request->withAttribute('noCache', true);
113
            $addInformationAboutDisabledCache = true;
114
        }
115
116
        $response = $handler->handle($request);
117
118
        // Caching is disabled, because otherwise generated URLs could include the ADMCMD_noBeUser parameter
119
        if ($addInformationAboutDisabledCache) {
120
            $GLOBALS['TSFE']->set_no_cache('GET Parameter ADMCMD_noBeUser was given', true);
121
        }
122
123
        // Add an info box to the frontend content
124
        if ($GLOBALS['TSFE']->doWorkspacePreview()) {
125
            $previewInfo = $this->renderPreviewInfo($GLOBALS['TSFE'], $request->getAttribute('normalizedParams'));
126
            $body = $response->getBody();
127
            $body->rewind();
128
            $content = $body->getContents();
129
            $content = str_ireplace('</body>', $previewInfo . '</body>', $content);
130
            $body = new Stream('php://temp', 'rw');
131
            $body->write($content);
132
            $response = $response->withBody($body);
133
        }
134
135
        return $response;
136
    }
137
138
    /**
139
     * Renders the logout template when the "logout" button was pressed.
140
     * Returns a string which can be put into a HttpResponse.
141
     *
142
     * @param string $returnUrl
143
     * @return string
144
     */
145
    protected function getLogoutTemplateMessage(string $returnUrl = ''): string
146
    {
147
        $returnUrl = GeneralUtility::sanitizeLocalUrl($returnUrl);
148
        $returnUrl = $this->removePreviewParameterFromUrl($returnUrl);
149
        if ($GLOBALS['TYPO3_CONF_VARS']['FE']['workspacePreviewLogoutTemplate']) {
150
            $templateFile = GeneralUtility::getFileAbsFileName($GLOBALS['TYPO3_CONF_VARS']['FE']['workspacePreviewLogoutTemplate']);
151
            if (@is_file($templateFile)) {
152
                $message = file_get_contents($templateFile);
153
            } else {
154
                $message = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_mod.xlf:previewLogoutError');
155
                $message = htmlspecialchars($message);
156
                $message = sprintf($message, '<strong>', '</strong><br>', $templateFile);
157
            }
158
        } else {
159
            $message = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_mod.xlf:previewLogoutSuccess');
160
            $message = htmlspecialchars($message);
161
            $message = sprintf($message, '<a href="' . htmlspecialchars($returnUrl) . '">', '</a>');
162
        }
163
        return sprintf($message, htmlspecialchars($returnUrl));
164
    }
165
166
    /**
167
     * Looking for an ADMCMD_prev code, looks it up if found and returns configuration data.
168
     * Background: From the backend a request to the frontend to show a page, possibly with
169
     * workspace preview can be "recorded" and associated with a keyword.
170
     * When the frontend is requested with this keyword the associated request parameters are
171
     * restored from the database AND the backend user is loaded - only for that request.
172
     * The main point is that a special URL valid for a limited time,
173
     * eg. http://localhost/typo3site/index.php?ADMCMD_prev=035d9bf938bd23cb657735f68a8cedbf will
174
     * open up for a preview that doesn't require login. Thus it's useful for sending in an email
175
     * to someone without backend account.
176
     *
177
     * @param ServerRequestInterface $request
178
     * @param string $inputCode
179
     * @return array|null Preview configuration array from sys_preview record.
180
     * @throws \Exception
181
     */
182
    protected function getPreviewConfigurationFromRequest(ServerRequestInterface $request, string $inputCode): ?array
183
    {
184
        $previewData = $this->getPreviewData($inputCode);
185
        if (!is_array($previewData)) {
186
            // ADMCMD command could not be executed! (No keyword configuration found)
187
            return null;
188
        }
189
        if ($request->getMethod() === 'POST') {
190
            throw new \Exception('POST requests are incompatible with keyword preview.', 1294585191);
191
        }
192
        // Validate configuration
193
        $previewConfig = json_decode($previewData['config'], true);
194
        if (!$previewConfig['fullWorkspace']) {
195
            throw new \Exception('Preview configuration did not include a workspace preview', 1294585190);
196
        }
197
        // If the GET parameter ADMCMD_prev is set, then a cookie is set for the next request
198
        if ($request->getQueryParams()[$this->previewKey] ?? false) {
199
            $this->setCookie($inputCode, $request->getAttribute('normalizedParams'));
200
        }
201
        return $previewConfig;
202
    }
203
204
    /**
205
     * Creates a preview user and sets the workspace ID and the current page ID (for accessing the page)
206
     *
207
     * @param int $workspaceUid the workspace ID to set
208
     * @param mixed $requestedPageId pageID to the current page
209
     * @return PreviewUserAuthentication|bool if the set up of the workspace was successful, the user is returned.
210
     */
211
    protected function initializePreviewUser(int $workspaceUid, $requestedPageId)
212
    {
213
        if ($workspaceUid > 0) {
214
            $previewUser = GeneralUtility::makeInstance(PreviewUserAuthentication::class);
215
            $previewUser->setWebmounts([$requestedPageId]);
216
            if ($previewUser->setTemporaryWorkspace($workspaceUid)) {
217
                return $previewUser;
218
            }
219
        }
220
        return false;
221
    }
222
223
    /**
224
     * Sets a cookie for logging in a preview user
225
     *
226
     * @param string $inputCode
227
     * @param NormalizedParams $normalizedParams
228
     */
229
    protected function setCookie(string $inputCode, NormalizedParams $normalizedParams)
230
    {
231
        $cookieSameSite = $this->sanitizeSameSiteCookieValue(
232
            strtolower($GLOBALS['TYPO3_CONF_VARS']['BE']['cookieSameSite'] ?? Cookie::SAMESITE_STRICT)
233
        );
234
        // None needs the secure option (only allowed on HTTPS)
235
        $cookieSecure = $cookieSameSite === Cookie::SAMESITE_NONE || $normalizedParams->isHttps();
236
237
        $cookie = new Cookie(
238
            $this->previewKey,
239
            $inputCode,
240
            0,
241
            $normalizedParams->getSitePath(),
242
            null,
243
            $cookieSecure,
244
            true,
245
            false,
246
            $cookieSameSite
247
        );
248
        header('Set-Cookie: ' . $cookie->__toString(), false);
249
    }
250
251
    /**
252
     * Returns the input code value from the admin command variable
253
     * If no inputcode and a cookie is set, load input code from cookie
254
     *
255
     * @param ServerRequestInterface $request
256
     * @return string keyword
257
     */
258
    protected function getPreviewInputCode(ServerRequestInterface $request): string
259
    {
260
        return $request->getQueryParams()[$this->previewKey] ?? $request->getCookieParams()[$this->previewKey] ?? '';
261
    }
262
263
    /**
264
     * Look for keyword configuration record in the database, but check if the keyword has expired already
265
     *
266
     * @param string $keyword
267
     * @return mixed array of the result set or null
268
     */
269
    protected function getPreviewData(string $keyword)
270
    {
271
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
272
            ->getQueryBuilderForTable('sys_preview');
273
        return $queryBuilder
274
            ->select('*')
275
            ->from('sys_preview')
276
            ->where(
277
                $queryBuilder->expr()->eq(
278
                    'keyword',
279
                    $queryBuilder->createNamedParameter($keyword)
280
                ),
281
                $queryBuilder->expr()->gt(
282
                    'endtime',
283
                    $queryBuilder->createNamedParameter($GLOBALS['EXEC_TIME'], \PDO::PARAM_INT)
284
                )
285
            )
286
            ->setMaxResults(1)
287
            ->execute()
288
            ->fetch();
289
    }
290
291
    /**
292
     * Code regarding adding a custom preview message, when previewing a workspace
293
     */
294
295
    /**
296
     * Renders a message at the bottom of the HTML page, can be modified via
297
     *
298
     *   config.disablePreviewNotification = 1 (to disable the additional info text)
299
     *
300
     * and
301
     *
302
     *   config.message_preview_workspace = This is not the online version but the version of "%s" workspace (ID: %s).
303
     *
304
     * via TypoScript.
305
     *
306
     * @param TypoScriptFrontendController $tsfe
307
     * @param NormalizedParams $normalizedParams
308
     * @return string
309
     */
310
    protected function renderPreviewInfo(TypoScriptFrontendController $tsfe, NormalizedParams $normalizedParams): string
311
    {
312
        $content = '';
313
        if (!isset($tsfe->config['config']['disablePreviewNotification']) || (int)$tsfe->config['config']['disablePreviewNotification'] !== 1) {
314
            // get the title of the current workspace
315
            $currentWorkspaceId = $tsfe->whichWorkspace();
316
            $currentWorkspaceTitle = $this->getWorkspaceTitle($currentWorkspaceId);
317
            $currentWorkspaceTitle = htmlspecialchars($currentWorkspaceTitle);
318
            if ($tsfe->config['config']['message_preview_workspace']) {
319
                $content = sprintf(
320
                    $tsfe->config['config']['message_preview_workspace'],
321
                    $currentWorkspaceTitle,
322
                    $currentWorkspaceId ?? -99
323
                );
324
            } else {
325
                $text = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_mod.xlf:previewText');
326
                $text = htmlspecialchars($text);
327
                $text = sprintf($text, $currentWorkspaceTitle, $currentWorkspaceId ?? -99);
328
                $stopPreviewText = $this->getLanguageService()->sL('LLL:EXT:workspaces/Resources/Private/Language/locallang_mod.xlf:stopPreview');
329
                $stopPreviewText = htmlspecialchars($stopPreviewText);
330
                if ($GLOBALS['BE_USER'] instanceof PreviewUserAuthentication) {
331
                    $url = $this->removePreviewParameterFromUrl($normalizedParams->getRequestUri());
332
                    $urlForStoppingPreview = $normalizedParams->getSiteUrl() . 'index.php?returnUrl=' . rawurlencode($url) . '&ADMCMD_prev=LOGOUT';
333
                    $text .= '<br><a style="color: #000; pointer-events: visible;" href="' . htmlspecialchars($urlForStoppingPreview) . '">' . $stopPreviewText . '</a>';
334
                }
335
                $styles = [];
336
                $styles[] = 'position: fixed';
337
                $styles[] = 'top: 15px';
338
                $styles[] = 'right: 15px';
339
                $styles[] = 'padding: 8px 18px';
340
                $styles[] = 'background: #fff3cd';
341
                $styles[] = 'border: 1px solid #ffeeba';
342
                $styles[] = 'font-family: sans-serif';
343
                $styles[] = 'font-size: 14px';
344
                $styles[] = 'font-weight: bold';
345
                $styles[] = 'color: #856404';
346
                $styles[] = 'z-index: 20000';
347
                $styles[] = 'user-select: none';
348
                $styles[] = 'pointer-events: none';
349
                $styles[] = 'text-align: center';
350
                $styles[] = 'border-radius: 2px';
351
                $content = '<div id="typo3-preview-info" style="' . implode(';', $styles) . '">' . $text . '</div>';
352
            }
353
        }
354
        return $content;
355
    }
356
357
    /**
358
     * Fetches the title of the workspace
359
     *
360
     * @param int $workspaceId
361
     * @return string the title of the workspace
362
     */
363
    protected function getWorkspaceTitle(int $workspaceId): string
364
    {
365
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
366
            ->getQueryBuilderForTable('sys_workspace');
367
        $title = $queryBuilder
368
            ->select('title')
369
            ->from('sys_workspace')
370
            ->where(
371
                $queryBuilder->expr()->eq(
372
                    'uid',
373
                    $queryBuilder->createNamedParameter($workspaceId, \PDO::PARAM_INT)
374
                )
375
            )
376
            ->execute()
377
            ->fetchColumn();
378
        return (string)($title !== false ? $title : '');
379
    }
380
381
    /**
382
     * Used for generating URLs (e.g. in logout page) without the existing ADMCMD_prev keyword as GET variable
383
     *
384
     * @param string $url
385
     * @return string
386
     */
387
    protected function removePreviewParameterFromUrl(string $url): string
388
    {
389
        return (string)preg_replace('/\\&?' . $this->previewKey . '=[[:alnum:]]+/', '', $url);
390
    }
391
392
    /**
393
     * @return LanguageService
394
     */
395
    protected function getLanguageService(): LanguageService
396
    {
397
        return $GLOBALS['LANG'] ?: GeneralUtility::makeInstance(LanguageService::class);
398
    }
399
400
    /**
401
     * Register the backend user as aspect
402
     *
403
     * @param Context $context
404
     * @param BackendUserAuthentication $user
405
     */
406
    protected function setBackendUserAspect(Context $context, BackendUserAuthentication $user = null)
407
    {
408
        $context->setAspect('backend.user', GeneralUtility::makeInstance(UserAspect::class, $user));
409
        $context->setAspect('workspace', GeneralUtility::makeInstance(WorkspaceAspect::class, $user ? $user->workspace : 0));
410
    }
411
}
412