Passed
Push — master ( 270b5f...31b32c )
by
unknown
18:55
created

getPreviewConfigurationFromRequest()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 11
c 1
b 0
f 0
nc 5
nop 2
dl 0
loc 20
rs 9.6111
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\RouteResultInterface;
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
        $setCookieOnCurrentRequest = false;
77
        /** @var NormalizedParams $normalizedParams */
78
        $normalizedParams = $request->getAttribute('normalizedParams');
79
        $context = GeneralUtility::makeInstance(Context::class);
80
81
        // First, if a Log out is happening, a custom HTML output page is shown and the request exits with removing
82
        // the cookie for the backend preview.
83
        if ($keyword === 'LOGOUT') {
84
            // "log out", and unset the cookie
85
            $message = $this->getLogoutTemplateMessage($request->getQueryParams()['returnUrl'] ?? '');
86
            $response = new HtmlResponse($message);
87
            return $this->addCookie('', $normalizedParams, $response);
88
        }
89
90
        // If the keyword is ignore, then the preview is not managed as "Preview User" but handled
91
        // via the regular backend user or even no user if the GET parameter ADMCMD_noBeUser is set
92
        if (!empty($keyword) && $keyword !== 'IGNORE') {
93
            $routeResult = $request->getAttribute('routing', null);
94
            // A keyword was found in a query parameter or in a cookie
95
            // If the keyword is valid, activate a BE User and override any existing BE Users
96
            // (in case workspace ID was given and a corresponding site to be used was found)
97
            $previewWorkspaceId = $this->getWorkspaceIdFromRequest($request, $keyword);
98
            if ($previewWorkspaceId > 0 && $routeResult instanceof RouteResultInterface) {
99
                $previewUser = $this->initializePreviewUser($previewWorkspaceId);
0 ignored issues
show
Bug introduced by
It seems like $previewWorkspaceId can also be of type null; however, parameter $workspaceUid of TYPO3\CMS\Workspaces\Mid...initializePreviewUser() does only seem to accept integer, 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

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