<?php 
 
/* 
 * This file is part of the Symfony package. 
 * 
 * (c) Fabien Potencier <[email protected]> 
 * 
 * For the full copyright and license information, please view the LICENSE 
 * file that was distributed with this source code. 
 */ 
 
namespace Symfony\Component\HttpKernel\EventListener; 
 
use Psr\Container\ContainerInterface; 
use Symfony\Component\EventDispatcher\EventSubscriberInterface; 
use Symfony\Component\HttpFoundation\Cookie; 
use Symfony\Component\HttpFoundation\Session\Session; 
use Symfony\Component\HttpFoundation\Session\SessionInterface; 
use Symfony\Component\HttpFoundation\Session\SessionUtils; 
use Symfony\Component\HttpKernel\Event\FinishRequestEvent; 
use Symfony\Component\HttpKernel\Event\RequestEvent; 
use Symfony\Component\HttpKernel\Event\ResponseEvent; 
use Symfony\Component\HttpKernel\Exception\UnexpectedSessionUsageException; 
use Symfony\Component\HttpKernel\KernelEvents; 
use Symfony\Contracts\Service\ResetInterface; 
 
/** 
 * Sets the session onto the request on the "kernel.request" event and saves 
 * it on the "kernel.response" event. 
 * 
 * In addition, if the session has been started it overrides the Cache-Control 
 * header in such a way that all caching is disabled in that case. 
 * If you have a scenario where caching responses with session information in 
 * them makes sense, you can disable this behaviour by setting the header 
 * AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER on the response. 
 * 
 * @author Johannes M. Schmitt <[email protected]> 
 * @author Tobias Schultze <http://tobion.de> 
 */ 
abstract class AbstractSessionListener implements EventSubscriberInterface, ResetInterface 
{ 
    public const NO_AUTO_CACHE_CONTROL_HEADER = 'Symfony-Session-NoAutoCacheControl'; 
 
 
    /** 
     * @internal 
     */ 
    protected $container; 
    private $sessionUsageStack = []; 
    private $debug; 
 
    /** 
     * @var array<string, mixed> 
     */ 
    private $sessionOptions; 
 
    /** 
     * @internal 
     */ 
    public function __construct(?ContainerInterface $container = null, bool $debug = false, array $sessionOptions = []) 
    { 
        $this->container = $container; 
        $this->debug = $debug; 
        $this->sessionOptions = $sessionOptions; 
    } 
 
    /** 
     * @internal 
     */ 
    public function onKernelRequest(RequestEvent $event) 
    { 
        if (!$event->isMainRequest()) { 
            return; 
        } 
 
        $request = $event->getRequest(); 
        if (!$request->hasSession()) { 
            // This variable prevents calling `$this->getSession()` twice in case the Request (and the below factory) is cloned 
            $sess = null; 
            $request->setSessionFactory(function () use (&$sess, $request) { 
                if (!$sess) { 
                    $sess = $this->getSession(); 
                    $request->setSession($sess); 
 
                    /* 
                     * For supporting sessions in php runtime with runners like roadrunner or swoole, the session 
                     * cookie needs to be read from the cookie bag and set on the session storage. 
                     * 
                     * Do not set it when a native php session is active. 
                     */ 
                    if ($sess && !$sess->isStarted() && \PHP_SESSION_ACTIVE !== session_status()) { 
                        $sessionId = $sess->getId() ?: $request->cookies->get($sess->getName(), ''); 
                        $sess->setId($sessionId); 
                    } 
                } 
 
                return $sess; 
            }); 
        } 
 
        $session = $this->container && $this->container->has('initialized_session') ? $this->container->get('initialized_session') : null; 
        $this->sessionUsageStack[] = $session instanceof Session ? $session->getUsageIndex() : 0; 
    } 
 
    /** 
     * @internal 
     */ 
    public function onKernelResponse(ResponseEvent $event) 
    { 
        if (!$event->isMainRequest() || (!$this->container->has('initialized_session') && !$event->getRequest()->hasSession())) { 
            return; 
        } 
 
        $response = $event->getResponse(); 
        $autoCacheControl = !$response->headers->has(self::NO_AUTO_CACHE_CONTROL_HEADER); 
        // Always remove the internal header if present 
        $response->headers->remove(self::NO_AUTO_CACHE_CONTROL_HEADER); 
 
        if (!$session = $this->container && $this->container->has('initialized_session') ? $this->container->get('initialized_session') : ($event->getRequest()->hasSession() ? $event->getRequest()->getSession() : null)) { 
            return; 
        } 
 
        if ($session->isStarted()) { 
            /* 
             * Saves the session, in case it is still open, before sending the response/headers. 
             * 
             * This ensures several things in case the developer did not save the session explicitly: 
             * 
             *  * If a session save handler without locking is used, it ensures the data is available 
             *    on the next request, e.g. after a redirect. PHPs auto-save at script end via 
             *    session_register_shutdown is executed after fastcgi_finish_request. So in this case 
             *    the data could be missing the next request because it might not be saved the moment 
             *    the new request is processed. 
             *  * A locking save handler (e.g. the native 'files') circumvents concurrency problems like 
             *    the one above. But by saving the session before long-running things in the terminate event, 
             *    we ensure the session is not blocked longer than needed. 
             *  * When regenerating the session ID no locking is involved in PHPs session design. See 
             *    https://bugs.php.net/61470 for a discussion. So in this case, the session must 
             *    be saved anyway before sending the headers with the new session ID. Otherwise session 
             *    data could get lost again for concurrent requests with the new ID. One result could be 
             *    that you get logged out after just logging in. 
             * 
             * This listener should be executed as one of the last listeners, so that previous listeners 
             * can still operate on the open session. This prevents the overhead of restarting it. 
             * Listeners after closing the session can still work with the session as usual because 
             * Symfonys session implementation starts the session on demand. So writing to it after 
             * it is saved will just restart it. 
             */ 
            $session->save(); 
 
            /* 
             * For supporting sessions in php runtime with runners like roadrunner or swoole the session 
             * cookie need to be written on the response object and should not be written by PHP itself. 
             */ 
            $sessionName = $session->getName(); 
            $sessionId = $session->getId(); 
            $sessionOptions = $this->getSessionOptions($this->sessionOptions); 
            $sessionCookiePath = $sessionOptions['cookie_path'] ?? '/'; 
            $sessionCookieDomain = $sessionOptions['cookie_domain'] ?? null; 
            $sessionCookieSecure = $sessionOptions['cookie_secure'] ?? false; 
            $sessionCookieHttpOnly = $sessionOptions['cookie_httponly'] ?? true; 
            $sessionCookieSameSite = $sessionOptions['cookie_samesite'] ?? Cookie::SAMESITE_LAX; 
            $sessionUseCookies = $sessionOptions['use_cookies'] ?? true; 
 
            SessionUtils::popSessionCookie($sessionName, $sessionId); 
 
            if ($sessionUseCookies) { 
                $request = $event->getRequest(); 
                $requestSessionCookieId = $request->cookies->get($sessionName); 
 
                $isSessionEmpty = $session->isEmpty() && empty($_SESSION); // checking $_SESSION to keep compatibility with native sessions 
                if ($requestSessionCookieId && $isSessionEmpty) { 
                    // PHP internally sets the session cookie value to "deleted" when setcookie() is called with empty string $value argument 
                    // which happens in \Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler::destroy 
                    // when the session gets invalidated (for example on logout) so we must handle this case here too 
                    // otherwise we would send two Set-Cookie headers back with the response 
                    SessionUtils::popSessionCookie($sessionName, 'deleted'); 
                    $response->headers->clearCookie( 
                        $sessionName, 
                        $sessionCookiePath, 
                        $sessionCookieDomain, 
                        $sessionCookieSecure, 
                        $sessionCookieHttpOnly, 
                        $sessionCookieSameSite 
                    ); 
                } elseif ($sessionId !== $requestSessionCookieId && !$isSessionEmpty) { 
                    $expire = 0; 
                    $lifetime = $sessionOptions['cookie_lifetime'] ?? null; 
                    if ($lifetime) { 
                        $expire = time() + $lifetime; 
                    } 
 
                    $response->headers->setCookie( 
                        Cookie::create( 
                            $sessionName, 
                            $sessionId, 
                            $expire, 
                            $sessionCookiePath, 
                            $sessionCookieDomain, 
                            $sessionCookieSecure, 
                            $sessionCookieHttpOnly, 
                            false, 
                            $sessionCookieSameSite 
                        ) 
                    ); 
                } 
            } 
        } 
 
        if ($session instanceof Session ? $session->getUsageIndex() === end($this->sessionUsageStack) : !$session->isStarted()) { 
            return; 
        } 
 
        if ($autoCacheControl) { 
            $maxAge = $response->headers->hasCacheControlDirective('public') ? 0 : (int) $response->getMaxAge(); 
            $response 
                ->setExpires(new \DateTimeImmutable('+'.$maxAge.' seconds')) 
                ->setPrivate() 
                ->setMaxAge($maxAge) 
                ->headers->addCacheControlDirective('must-revalidate'); 
        } 
 
        if (!$event->getRequest()->attributes->get('_stateless', false)) { 
            return; 
        } 
 
        if ($this->debug) { 
            throw new UnexpectedSessionUsageException('Session was used while the request was declared stateless.'); 
        } 
 
        if ($this->container->has('logger')) { 
            $this->container->get('logger')->warning('Session was used while the request was declared stateless.'); 
        } 
    } 
 
    /** 
     * @internal 
     */ 
    public function onFinishRequest(FinishRequestEvent $event) 
    { 
        if ($event->isMainRequest()) { 
            array_pop($this->sessionUsageStack); 
        } 
    } 
 
    /** 
     * @internal 
     */ 
    public function onSessionUsage(): void 
    { 
        if (!$this->debug) { 
            return; 
        } 
 
        if ($this->container && $this->container->has('session_collector')) { 
            $this->container->get('session_collector')(); 
        } 
 
        if (!$requestStack = $this->container && $this->container->has('request_stack') ? $this->container->get('request_stack') : null) { 
            return; 
        } 
 
        $stateless = false; 
        $clonedRequestStack = clone $requestStack; 
        while (null !== ($request = $clonedRequestStack->pop()) && !$stateless) { 
            $stateless = $request->attributes->get('_stateless'); 
        } 
 
        if (!$stateless) { 
            return; 
        } 
 
        if (!$session = $this->container && $this->container->has('initialized_session') ? $this->container->get('initialized_session') : $requestStack->getCurrentRequest()->getSession()) { 
            return; 
        } 
 
        if ($session->isStarted()) { 
            $session->save(); 
        } 
 
        throw new UnexpectedSessionUsageException('Session was used while the request was declared stateless.'); 
    } 
 
    /** 
     * @internal 
     */ 
    public static function getSubscribedEvents(): array 
    { 
        return [ 
            KernelEvents::REQUEST => ['onKernelRequest', 128], 
            // low priority to come after regular response listeners, but higher than StreamedResponseListener 
            KernelEvents::RESPONSE => ['onKernelResponse', -1000], 
            KernelEvents::FINISH_REQUEST => ['onFinishRequest'], 
        ]; 
    } 
 
    /** 
     * @internal 
     */ 
    public function reset(): void 
    { 
        if (\PHP_SESSION_ACTIVE === session_status()) { 
            session_abort(); 
        } 
 
        session_unset(); 
        $_SESSION = []; 
 
        if (!headers_sent()) { // session id can only be reset when no headers were so we check for headers_sent first 
            session_id(''); 
        } 
    } 
 
    /** 
     * Gets the session object. 
     * 
     * @internal 
     * 
     * @return SessionInterface|null 
     */ 
    abstract protected function getSession(); 
 
    private function getSessionOptions(array $sessionOptions): array 
    { 
        $mergedSessionOptions = []; 
 
        foreach (session_get_cookie_params() as $key => $value) { 
            $mergedSessionOptions['cookie_'.$key] = $value; 
        } 
 
        foreach ($sessionOptions as $key => $value) { 
            // do the same logic as in the NativeSessionStorage 
            if ('cookie_secure' === $key && 'auto' === $value) { 
                continue; 
            } 
            $mergedSessionOptions[$key] = $value; 
        } 
 
        return $mergedSessionOptions; 
    } 
}