Build modified streamline images

The official image from streamline does not currently work for the arm64
platform. As a temporary measure, the source code and docker build scripts
have been lifted from the official images and are used to build locally.

Some additional modifications are made to reduce overall image size, these
are documented in docker/README.md
This commit is contained in:
2024-03-10 16:17:50 -07:00
parent 2ffc3c408a
commit a424394109
13506 changed files with 1860172 additions and 6 deletions
@@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use Symfony\Component\HttpFoundation\Session\SessionUtils;
/**
* This abstract session handler provides a generic implementation
* of the PHP 7.0 SessionUpdateTimestampHandlerInterface,
* enabling strict and lazy session handling.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
abstract class AbstractSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface
{
private string $sessionName;
private string $prefetchId;
private string $prefetchData;
private ?string $newSessionId = null;
private string $igbinaryEmptyData;
public function open(string $savePath, string $sessionName): bool
{
$this->sessionName = $sessionName;
if (!headers_sent() && !\ini_get('session.cache_limiter') && '0' !== \ini_get('session.cache_limiter')) {
header(sprintf('Cache-Control: max-age=%d, private, must-revalidate', 60 * (int) \ini_get('session.cache_expire')));
}
return true;
}
abstract protected function doRead(#[\SensitiveParameter] string $sessionId): string;
abstract protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool;
abstract protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool;
public function validateId(#[\SensitiveParameter] string $sessionId): bool
{
$this->prefetchData = $this->read($sessionId);
$this->prefetchId = $sessionId;
return '' !== $this->prefetchData;
}
public function read(#[\SensitiveParameter] string $sessionId): string
{
if (isset($this->prefetchId)) {
$prefetchId = $this->prefetchId;
$prefetchData = $this->prefetchData;
unset($this->prefetchId, $this->prefetchData);
if ($prefetchId === $sessionId || '' === $prefetchData) {
$this->newSessionId = '' === $prefetchData ? $sessionId : null;
return $prefetchData;
}
}
$data = $this->doRead($sessionId);
$this->newSessionId = '' === $data ? $sessionId : null;
return $data;
}
public function write(#[\SensitiveParameter] string $sessionId, string $data): bool
{
// see https://github.com/igbinary/igbinary/issues/146
$this->igbinaryEmptyData ??= \function_exists('igbinary_serialize') ? igbinary_serialize([]) : '';
if ('' === $data || $this->igbinaryEmptyData === $data) {
return $this->destroy($sessionId);
}
$this->newSessionId = null;
return $this->doWrite($sessionId, $data);
}
public function destroy(#[\SensitiveParameter] string $sessionId): bool
{
if (!headers_sent() && filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL)) {
if (!isset($this->sessionName)) {
throw new \LogicException(sprintf('Session name cannot be empty, did you forget to call "parent::open()" in "%s"?.', static::class));
}
$cookie = SessionUtils::popSessionCookie($this->sessionName, $sessionId);
/*
* We send an invalidation Set-Cookie header (zero lifetime)
* when either the session was started or a cookie with
* the session name was sent by the client (in which case
* we know it's invalid as a valid session cookie would've
* started the session).
*/
if (null === $cookie || isset($_COOKIE[$this->sessionName])) {
$params = session_get_cookie_params();
unset($params['lifetime']);
setcookie($this->sessionName, '', $params);
}
}
return $this->newSessionId === $sessionId || $this->doDestroy($sessionId);
}
}
@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
/**
* @author Ahmed TAILOULOUTE <ahmed.tailouloute@gmail.com>
*/
class IdentityMarshaller implements MarshallerInterface
{
public function marshall(array $values, ?array &$failed): array
{
foreach ($values as $key => $value) {
if (!\is_string($value)) {
throw new \LogicException(sprintf('%s accepts only string as data.', __METHOD__));
}
}
return $values;
}
public function unmarshall(string $value): string
{
return $value;
}
}
@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;
/**
* @author Ahmed TAILOULOUTE <ahmed.tailouloute@gmail.com>
*/
class MarshallingSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface
{
private AbstractSessionHandler $handler;
private MarshallerInterface $marshaller;
public function __construct(AbstractSessionHandler $handler, MarshallerInterface $marshaller)
{
$this->handler = $handler;
$this->marshaller = $marshaller;
}
public function open(string $savePath, string $name): bool
{
return $this->handler->open($savePath, $name);
}
public function close(): bool
{
return $this->handler->close();
}
public function destroy(#[\SensitiveParameter] string $sessionId): bool
{
return $this->handler->destroy($sessionId);
}
public function gc(int $maxlifetime): int|false
{
return $this->handler->gc($maxlifetime);
}
public function read(#[\SensitiveParameter] string $sessionId): string
{
return $this->marshaller->unmarshall($this->handler->read($sessionId));
}
public function write(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$failed = [];
$marshalledData = $this->marshaller->marshall(['data' => $data], $failed);
if (isset($failed['data'])) {
return false;
}
return $this->handler->write($sessionId, $marshalledData['data']);
}
public function validateId(#[\SensitiveParameter] string $sessionId): bool
{
return $this->handler->validateId($sessionId);
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return $this->handler->updateTimestamp($sessionId, $data);
}
}
@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* Memcached based session storage handler based on the Memcached class
* provided by the PHP memcached extension.
*
* @see https://php.net/memcached
*
* @author Drak <drak@zikula.org>
*/
class MemcachedSessionHandler extends AbstractSessionHandler
{
private \Memcached $memcached;
/**
* Time to live in seconds.
*/
private int|\Closure|null $ttl;
/**
* Key prefix for shared environments.
*/
private string $prefix;
/**
* Constructor.
*
* List of available options:
* * prefix: The prefix to use for the memcached keys in order to avoid collision
* * ttl: The time to live in seconds.
*
* @throws \InvalidArgumentException When unsupported options are passed
*/
public function __construct(\Memcached $memcached, array $options = [])
{
$this->memcached = $memcached;
if ($diff = array_diff(array_keys($options), ['prefix', 'expiretime', 'ttl'])) {
throw new \InvalidArgumentException(sprintf('The following options are not supported "%s".', implode(', ', $diff)));
}
$this->ttl = $options['expiretime'] ?? $options['ttl'] ?? null;
$this->prefix = $options['prefix'] ?? 'sf2s';
}
public function close(): bool
{
return $this->memcached->quit();
}
protected function doRead(#[\SensitiveParameter] string $sessionId): string
{
return $this->memcached->get($this->prefix.$sessionId) ?: '';
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$this->memcached->touch($this->prefix.$sessionId, $this->getCompatibleTtl());
return true;
}
protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return $this->memcached->set($this->prefix.$sessionId, $data, $this->getCompatibleTtl());
}
private function getCompatibleTtl(): int
{
$ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime');
// If the relative TTL that is used exceeds 30 days, memcached will treat the value as Unix time.
// We have to convert it to an absolute Unix time at this point, to make sure the TTL is correct.
if ($ttl > 60 * 60 * 24 * 30) {
$ttl += time();
}
return $ttl;
}
protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
{
$result = $this->memcached->delete($this->prefix.$sessionId);
return $result || \Memcached::RES_NOTFOUND == $this->memcached->getResultCode();
}
public function gc(int $maxlifetime): int|false
{
// not required here because memcached will auto expire the records anyhow.
return 0;
}
/**
* Return a Memcached instance.
*/
protected function getMemcached(): \Memcached
{
return $this->memcached;
}
}
@@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* Migrating session handler for migrating from one handler to another. It reads
* from the current handler and writes both the current and new ones.
*
* It ignores errors from the new handler.
*
* @author Ross Motley <ross.motley@amara.com>
* @author Oliver Radwell <oliver.radwell@amara.com>
*/
class MigratingSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface
{
private \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface $currentHandler;
private \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface $writeOnlyHandler;
public function __construct(\SessionHandlerInterface $currentHandler, \SessionHandlerInterface $writeOnlyHandler)
{
if (!$currentHandler instanceof \SessionUpdateTimestampHandlerInterface) {
$currentHandler = new StrictSessionHandler($currentHandler);
}
if (!$writeOnlyHandler instanceof \SessionUpdateTimestampHandlerInterface) {
$writeOnlyHandler = new StrictSessionHandler($writeOnlyHandler);
}
$this->currentHandler = $currentHandler;
$this->writeOnlyHandler = $writeOnlyHandler;
}
public function close(): bool
{
$result = $this->currentHandler->close();
$this->writeOnlyHandler->close();
return $result;
}
public function destroy(#[\SensitiveParameter] string $sessionId): bool
{
$result = $this->currentHandler->destroy($sessionId);
$this->writeOnlyHandler->destroy($sessionId);
return $result;
}
public function gc(int $maxlifetime): int|false
{
$result = $this->currentHandler->gc($maxlifetime);
$this->writeOnlyHandler->gc($maxlifetime);
return $result;
}
public function open(string $savePath, string $sessionName): bool
{
$result = $this->currentHandler->open($savePath, $sessionName);
$this->writeOnlyHandler->open($savePath, $sessionName);
return $result;
}
public function read(#[\SensitiveParameter] string $sessionId): string
{
// No reading from new handler until switch-over
return $this->currentHandler->read($sessionId);
}
public function write(#[\SensitiveParameter] string $sessionId, string $sessionData): bool
{
$result = $this->currentHandler->write($sessionId, $sessionData);
$this->writeOnlyHandler->write($sessionId, $sessionData);
return $result;
}
public function validateId(#[\SensitiveParameter] string $sessionId): bool
{
// No reading from new handler until switch-over
return $this->currentHandler->validateId($sessionId);
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $sessionData): bool
{
$result = $this->currentHandler->updateTimestamp($sessionId, $sessionData);
$this->writeOnlyHandler->updateTimestamp($sessionId, $sessionData);
return $result;
}
}
@@ -0,0 +1,186 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use MongoDB\BSON\Binary;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Client;
use MongoDB\Driver\BulkWrite;
use MongoDB\Driver\Manager;
use MongoDB\Driver\Query;
/**
* Session handler using the MongoDB driver extension.
*
* @author Markus Bachmann <markus.bachmann@bachi.biz>
* @author Jérôme Tamarelle <jerome@tamarelle.net>
*
* @see https://php.net/mongodb
*/
class MongoDbSessionHandler extends AbstractSessionHandler
{
private Manager $manager;
private string $namespace;
private array $options;
private int|\Closure|null $ttl;
/**
* Constructor.
*
* List of available options:
* * database: The name of the database [required]
* * collection: The name of the collection [required]
* * id_field: The field name for storing the session id [default: _id]
* * data_field: The field name for storing the session data [default: data]
* * time_field: The field name for storing the timestamp [default: time]
* * expiry_field: The field name for storing the expiry-timestamp [default: expires_at]
* * ttl: The time to live in seconds.
*
* It is strongly recommended to put an index on the `expiry_field` for
* garbage-collection. Alternatively it's possible to automatically expire
* the sessions in the database as described below:
*
* A TTL collections can be used on MongoDB 2.2+ to cleanup expired sessions
* automatically. Such an index can for example look like this:
*
* db.<session-collection>.createIndex(
* { "<expiry-field>": 1 },
* { "expireAfterSeconds": 0 }
* )
*
* More details on: https://docs.mongodb.org/manual/tutorial/expire-data/
*
* If you use such an index, you can drop `gc_probability` to 0 since
* no garbage-collection is required.
*
* @throws \InvalidArgumentException When "database" or "collection" not provided
*/
public function __construct(Client|Manager $mongo, array $options)
{
if (!isset($options['database']) || !isset($options['collection'])) {
throw new \InvalidArgumentException('You must provide the "database" and "collection" option for MongoDBSessionHandler.');
}
if ($mongo instanceof Client) {
$mongo = $mongo->getManager();
}
$this->manager = $mongo;
$this->namespace = $options['database'].'.'.$options['collection'];
$this->options = array_merge([
'id_field' => '_id',
'data_field' => 'data',
'time_field' => 'time',
'expiry_field' => 'expires_at',
], $options);
$this->ttl = $this->options['ttl'] ?? null;
}
public function close(): bool
{
return true;
}
protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
{
$write = new BulkWrite();
$write->delete(
[$this->options['id_field'] => $sessionId],
['limit' => 1]
);
$this->manager->executeBulkWrite($this->namespace, $write);
return true;
}
public function gc(int $maxlifetime): int|false
{
$write = new BulkWrite();
$write->delete(
[$this->options['expiry_field'] => ['$lt' => $this->getUTCDateTime()]],
);
$result = $this->manager->executeBulkWrite($this->namespace, $write);
return $result->getDeletedCount() ?? false;
}
protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime');
$expiry = $this->getUTCDateTime($ttl);
$fields = [
$this->options['time_field'] => $this->getUTCDateTime(),
$this->options['expiry_field'] => $expiry,
$this->options['data_field'] => new Binary($data, Binary::TYPE_GENERIC),
];
$write = new BulkWrite();
$write->update(
[$this->options['id_field'] => $sessionId],
['$set' => $fields],
['upsert' => true]
);
$this->manager->executeBulkWrite($this->namespace, $write);
return true;
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime');
$expiry = $this->getUTCDateTime($ttl);
$write = new BulkWrite();
$write->update(
[$this->options['id_field'] => $sessionId],
['$set' => [
$this->options['time_field'] => $this->getUTCDateTime(),
$this->options['expiry_field'] => $expiry,
]],
['multi' => false],
);
$this->manager->executeBulkWrite($this->namespace, $write);
return true;
}
protected function doRead(#[\SensitiveParameter] string $sessionId): string
{
$cursor = $this->manager->executeQuery($this->namespace, new Query([
$this->options['id_field'] => $sessionId,
$this->options['expiry_field'] => ['$gte' => $this->getUTCDateTime()],
], [
'projection' => [
'_id' => false,
$this->options['data_field'] => true,
],
'limit' => 1,
]));
foreach ($cursor as $document) {
return (string) $document->{$this->options['data_field']} ?? '';
}
// Not found
return '';
}
private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime
{
return new UTCDateTime((time() + $additionalSeconds) * 1000);
}
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* Native session handler using PHP's built in file storage.
*
* @author Drak <drak@zikula.org>
*/
class NativeFileSessionHandler extends \SessionHandler
{
/**
* @param string|null $savePath Path of directory to save session files
* Default null will leave setting as defined by PHP.
* '/path', 'N;/path', or 'N;octal-mode;/path
*
* @see https://php.net/session.configuration#ini.session.save-path for further details.
*
* @throws \InvalidArgumentException On invalid $savePath
* @throws \RuntimeException When failing to create the save directory
*/
public function __construct(?string $savePath = null)
{
$baseDir = $savePath ??= \ini_get('session.save_path');
if ($count = substr_count($savePath, ';')) {
if ($count > 2) {
throw new \InvalidArgumentException(sprintf('Invalid argument $savePath \'%s\'.', $savePath));
}
// characters after last ';' are the path
$baseDir = ltrim(strrchr($savePath, ';'), ';');
}
if ($baseDir && !is_dir($baseDir) && !@mkdir($baseDir, 0777, true) && !is_dir($baseDir)) {
throw new \RuntimeException(sprintf('Session Storage was not able to create directory "%s".', $baseDir));
}
if ($savePath !== \ini_get('session.save_path')) {
ini_set('session.save_path', $savePath);
}
if ('files' !== \ini_get('session.save_handler')) {
ini_set('session.save_handler', 'files');
}
}
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* Can be used in unit testing or in a situations where persisted sessions are not desired.
*
* @author Drak <drak@zikula.org>
*/
class NullSessionHandler extends AbstractSessionHandler
{
public function close(): bool
{
return true;
}
public function validateId(#[\SensitiveParameter] string $sessionId): bool
{
return true;
}
protected function doRead(#[\SensitiveParameter] string $sessionId): string
{
return '';
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return true;
}
protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return true;
}
protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
{
return true;
}
public function gc(int $maxlifetime): int|false
{
return 0;
}
}
@@ -0,0 +1,901 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
/**
* Session handler using a PDO connection to read and write data.
*
* It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
* different locking strategies to handle concurrent access to the same session.
* Locking is necessary to prevent loss of data due to race conditions and to keep
* the session data consistent between read() and write(). With locking, requests
* for the same session will wait until the other one finished writing. For this
* reason it's best practice to close a session as early as possible to improve
* concurrency. PHPs internal files session handler also implements locking.
*
* Attention: Since SQLite does not support row level locks but locks the whole database,
* it means only one session can be accessed at a time. Even different sessions would wait
* for another to finish. So saving session in SQLite should only be considered for
* development or prototypes.
*
* Session data is a binary string that can contain non-printable characters like the null byte.
* For this reason it must be saved in a binary column in the database like BLOB in MySQL.
* Saving it in a character column could corrupt the data. You can use createTable()
* to initialize a correctly defined table.
*
* @see https://php.net/sessionhandlerinterface
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Michael Williams <michael.williams@funsational.com>
* @author Tobias Schultze <http://tobion.de>
*/
class PdoSessionHandler extends AbstractSessionHandler
{
/**
* No locking is done. This means sessions are prone to loss of data due to
* race conditions of concurrent requests to the same session. The last session
* write will win in this case. It might be useful when you implement your own
* logic to deal with this like an optimistic approach.
*/
public const LOCK_NONE = 0;
/**
* Creates an application-level lock on a session. The disadvantage is that the
* lock is not enforced by the database and thus other, unaware parts of the
* application could still concurrently modify the session. The advantage is it
* does not require a transaction.
* This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
*/
public const LOCK_ADVISORY = 1;
/**
* Issues a real row lock. Since it uses a transaction between opening and
* closing a session, you have to be careful when you use same database connection
* that you also use for your application logic. This mode is the default because
* it's the only reliable solution across DBMSs.
*/
public const LOCK_TRANSACTIONAL = 2;
private \PDO $pdo;
/**
* DSN string or null for session.save_path or false when lazy connection disabled.
*/
private string|false|null $dsn = false;
private string $driver;
private string $table = 'sessions';
private string $idCol = 'sess_id';
private string $dataCol = 'sess_data';
private string $lifetimeCol = 'sess_lifetime';
private string $timeCol = 'sess_time';
/**
* Time to live in seconds.
*/
private int|\Closure|null $ttl;
/**
* Username when lazy-connect.
*/
private ?string $username = null;
/**
* Password when lazy-connect.
*/
private ?string $password = null;
/**
* Connection options when lazy-connect.
*/
private array $connectionOptions = [];
/**
* The strategy for locking, see constants.
*/
private int $lockMode = self::LOCK_TRANSACTIONAL;
/**
* It's an array to support multiple reads before closing which is manual, non-standard usage.
*
* @var \PDOStatement[] An array of statements to release advisory locks
*/
private array $unlockStatements = [];
/**
* True when the current session exists but expired according to session.gc_maxlifetime.
*/
private bool $sessionExpired = false;
/**
* Whether a transaction is active.
*/
private bool $inTransaction = false;
/**
* Whether gc() has been called.
*/
private bool $gcCalled = false;
/**
* You can either pass an existing database connection as PDO instance or
* pass a DSN string that will be used to lazy-connect to the database
* when the session is actually used. Furthermore it's possible to pass null
* which will then use the session.save_path ini setting as PDO DSN parameter.
*
* List of available options:
* * db_table: The name of the table [default: sessions]
* * db_id_col: The column where to store the session id [default: sess_id]
* * db_data_col: The column where to store the session data [default: sess_data]
* * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
* * db_time_col: The column where to store the timestamp [default: sess_time]
* * db_username: The username when lazy-connect [default: '']
* * db_password: The password when lazy-connect [default: '']
* * db_connection_options: An array of driver-specific connection options [default: []]
* * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
* * ttl: The time to live in seconds.
*
* @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null
*
* @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
*/
public function __construct(#[\SensitiveParameter] \PDO|string|null $pdoOrDsn = null, #[\SensitiveParameter] array $options = [])
{
if ($pdoOrDsn instanceof \PDO) {
if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
throw new \InvalidArgumentException(sprintf('"%s" requires PDO error mode attribute be set to throw Exceptions (i.e. $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION)).', __CLASS__));
}
$this->pdo = $pdoOrDsn;
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
} elseif (\is_string($pdoOrDsn) && str_contains($pdoOrDsn, '://')) {
$this->dsn = $this->buildDsnFromUrl($pdoOrDsn);
} else {
$this->dsn = $pdoOrDsn;
}
$this->table = $options['db_table'] ?? $this->table;
$this->idCol = $options['db_id_col'] ?? $this->idCol;
$this->dataCol = $options['db_data_col'] ?? $this->dataCol;
$this->lifetimeCol = $options['db_lifetime_col'] ?? $this->lifetimeCol;
$this->timeCol = $options['db_time_col'] ?? $this->timeCol;
$this->username = $options['db_username'] ?? $this->username;
$this->password = $options['db_password'] ?? $this->password;
$this->connectionOptions = $options['db_connection_options'] ?? $this->connectionOptions;
$this->lockMode = $options['lock_mode'] ?? $this->lockMode;
$this->ttl = $options['ttl'] ?? null;
}
/**
* Adds the Table to the Schema if it doesn't exist.
*/
public function configureSchema(Schema $schema, ?\Closure $isSameDatabase = null): void
{
if ($schema->hasTable($this->table) || ($isSameDatabase && !$isSameDatabase($this->getConnection()->exec(...)))) {
return;
}
$table = $schema->createTable($this->table);
switch ($this->driver) {
case 'mysql':
$table->addColumn($this->idCol, Types::BINARY)->setLength(128)->setNotnull(true);
$table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true);
$table->addColumn($this->lifetimeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true);
$table->addColumn($this->timeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true);
$table->addOption('collate', 'utf8mb4_bin');
$table->addOption('engine', 'InnoDB');
break;
case 'sqlite':
$table->addColumn($this->idCol, Types::TEXT)->setNotnull(true);
$table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true);
$table->addColumn($this->lifetimeCol, Types::INTEGER)->setNotnull(true);
$table->addColumn($this->timeCol, Types::INTEGER)->setNotnull(true);
break;
case 'pgsql':
$table->addColumn($this->idCol, Types::STRING)->setLength(128)->setNotnull(true);
$table->addColumn($this->dataCol, Types::BINARY)->setNotnull(true);
$table->addColumn($this->lifetimeCol, Types::INTEGER)->setNotnull(true);
$table->addColumn($this->timeCol, Types::INTEGER)->setNotnull(true);
break;
case 'oci':
$table->addColumn($this->idCol, Types::STRING)->setLength(128)->setNotnull(true);
$table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true);
$table->addColumn($this->lifetimeCol, Types::INTEGER)->setNotnull(true);
$table->addColumn($this->timeCol, Types::INTEGER)->setNotnull(true);
break;
case 'sqlsrv':
$table->addColumn($this->idCol, Types::TEXT)->setLength(128)->setNotnull(true);
$table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true);
$table->addColumn($this->lifetimeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true);
$table->addColumn($this->timeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true);
break;
default:
throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
}
$table->setPrimaryKey([$this->idCol]);
$table->addIndex([$this->lifetimeCol], $this->lifetimeCol.'_idx');
}
/**
* Creates the table to store sessions which can be called once for setup.
*
* Session ID is saved in a column of maximum length 128 because that is enough even
* for a 512 bit configured session.hash_function like Whirlpool. Session data is
* saved in a BLOB. One could also use a shorter inlined varbinary column
* if one was sure the data fits into it.
*
* @return void
*
* @throws \PDOException When the table already exists
* @throws \DomainException When an unsupported PDO driver is used
*/
public function createTable()
{
// connect if we are not yet
$this->getConnection();
$sql = match ($this->driver) {
// We use varbinary for the ID column because it prevents unwanted conversions:
// - character set conversions between server and client
// - trailing space removal
// - case-insensitivity
// - language processing like é == e
'mysql' => "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER UNSIGNED NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8mb4_bin, ENGINE = InnoDB",
'sqlite' => "CREATE TABLE $this->table ($this->idCol TEXT NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
'pgsql' => "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol BYTEA NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
'oci' => "CREATE TABLE $this->table ($this->idCol VARCHAR2(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
'sqlsrv' => "CREATE TABLE $this->table ($this->idCol VARCHAR(128) NOT NULL PRIMARY KEY, $this->dataCol VARBINARY(MAX) NOT NULL, $this->lifetimeCol INTEGER NOT NULL, $this->timeCol INTEGER NOT NULL)",
default => throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)),
};
try {
$this->pdo->exec($sql);
$this->pdo->exec("CREATE INDEX {$this->lifetimeCol}_idx ON $this->table ($this->lifetimeCol)");
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
/**
* Returns true when the current session exists but expired according to session.gc_maxlifetime.
*
* Can be used to distinguish between a new session and one that expired due to inactivity.
*/
public function isSessionExpired(): bool
{
return $this->sessionExpired;
}
public function open(string $savePath, string $sessionName): bool
{
$this->sessionExpired = false;
if (!isset($this->pdo)) {
$this->connect($this->dsn ?: $savePath);
}
return parent::open($savePath, $sessionName);
}
public function read(#[\SensitiveParameter] string $sessionId): string
{
try {
return parent::read($sessionId);
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
public function gc(int $maxlifetime): int|false
{
// We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
// This way, pruning expired sessions does not block them from being started while the current session is used.
$this->gcCalled = true;
return 0;
}
protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
{
// delete the record associated with this id
$sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
try {
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->execute();
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
return true;
}
protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$maxlifetime = (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'));
try {
// We use a single MERGE SQL query when supported by the database.
$mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime);
if (null !== $mergeStmt) {
$mergeStmt->execute();
return true;
}
$updateStmt = $this->getUpdateStatement($sessionId, $data, $maxlifetime);
$updateStmt->execute();
// When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in
// duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
// We can just catch such an error and re-execute the update. This is similar to a serializable
// transaction with retry logic on serialization failures but without the overhead and without possible
// false positives due to longer gap locking.
if (!$updateStmt->rowCount()) {
try {
$insertStmt = $this->getInsertStatement($sessionId, $data, $maxlifetime);
$insertStmt->execute();
} catch (\PDOException $e) {
// Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
if (str_starts_with($e->getCode(), '23')) {
$updateStmt->execute();
} else {
throw $e;
}
}
}
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
return true;
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$expiry = time() + (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'));
try {
$updateStmt = $this->pdo->prepare(
"UPDATE $this->table SET $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id"
);
$updateStmt->bindValue(':id', $sessionId, \PDO::PARAM_STR);
$updateStmt->bindValue(':expiry', $expiry, \PDO::PARAM_INT);
$updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
$updateStmt->execute();
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
return true;
}
public function close(): bool
{
$this->commit();
while ($unlockStmt = array_shift($this->unlockStatements)) {
$unlockStmt->execute();
}
if ($this->gcCalled) {
$this->gcCalled = false;
// delete the session records that have expired
$sql = "DELETE FROM $this->table WHERE $this->lifetimeCol < :time";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
$stmt->execute();
}
if (false !== $this->dsn) {
unset($this->pdo, $this->driver); // only close lazy-connection
}
return true;
}
/**
* Lazy-connects to the database.
*/
private function connect(#[\SensitiveParameter] string $dsn): void
{
$this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
}
/**
* Builds a PDO DSN from a URL-like connection string.
*
* @todo implement missing support for oci DSN (which look totally different from other PDO ones)
*/
private function buildDsnFromUrl(#[\SensitiveParameter] string $dsnOrUrl): string
{
// (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid
$url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $dsnOrUrl);
$params = parse_url($url);
if (false === $params) {
return $dsnOrUrl; // If the URL is not valid, let's assume it might be a DSN already.
}
$params = array_map('rawurldecode', $params);
// Override the default username and password. Values passed through options will still win over these in the constructor.
if (isset($params['user'])) {
$this->username = $params['user'];
}
if (isset($params['pass'])) {
$this->password = $params['pass'];
}
if (!isset($params['scheme'])) {
throw new \InvalidArgumentException('URLs without scheme are not supported to configure the PdoSessionHandler.');
}
$driverAliasMap = [
'mssql' => 'sqlsrv',
'mysql2' => 'mysql', // Amazon RDS, for some weird reason
'postgres' => 'pgsql',
'postgresql' => 'pgsql',
'sqlite3' => 'sqlite',
];
$driver = $driverAliasMap[$params['scheme']] ?? $params['scheme'];
// Doctrine DBAL supports passing its internal pdo_* driver names directly too (allowing both dashes and underscores). This allows supporting the same here.
if (str_starts_with($driver, 'pdo_') || str_starts_with($driver, 'pdo-')) {
$driver = substr($driver, 4);
}
$dsn = null;
switch ($driver) {
case 'mysql':
$dsn = 'mysql:';
if ('' !== ($params['query'] ?? '')) {
$queryParams = [];
parse_str($params['query'], $queryParams);
if ('' !== ($queryParams['charset'] ?? '')) {
$dsn .= 'charset='.$queryParams['charset'].';';
}
if ('' !== ($queryParams['unix_socket'] ?? '')) {
$dsn .= 'unix_socket='.$queryParams['unix_socket'].';';
if (isset($params['path'])) {
$dbName = substr($params['path'], 1); // Remove the leading slash
$dsn .= 'dbname='.$dbName.';';
}
return $dsn;
}
}
// If "unix_socket" is not in the query, we continue with the same process as pgsql
// no break
case 'pgsql':
$dsn ??= 'pgsql:';
if (isset($params['host']) && '' !== $params['host']) {
$dsn .= 'host='.$params['host'].';';
}
if (isset($params['port']) && '' !== $params['port']) {
$dsn .= 'port='.$params['port'].';';
}
if (isset($params['path'])) {
$dbName = substr($params['path'], 1); // Remove the leading slash
$dsn .= 'dbname='.$dbName.';';
}
return $dsn;
case 'sqlite':
return 'sqlite:'.substr($params['path'], 1);
case 'sqlsrv':
$dsn = 'sqlsrv:server=';
if (isset($params['host'])) {
$dsn .= $params['host'];
}
if (isset($params['port']) && '' !== $params['port']) {
$dsn .= ','.$params['port'];
}
if (isset($params['path'])) {
$dbName = substr($params['path'], 1); // Remove the leading slash
$dsn .= ';Database='.$dbName;
}
return $dsn;
default:
throw new \InvalidArgumentException(sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme']));
}
}
/**
* Helper method to begin a transaction.
*
* Since SQLite does not support row level locks, we have to acquire a reserved lock
* on the database immediately. Because of https://bugs.php.net/42766 we have to create
* such a transaction manually which also means we cannot use PDO::commit or
* PDO::rollback or PDO::inTransaction for SQLite.
*
* Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
* due to https://percona.com/blog/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
* So we change it to READ COMMITTED.
*/
private function beginTransaction(): void
{
if (!$this->inTransaction) {
if ('sqlite' === $this->driver) {
$this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
} else {
if ('mysql' === $this->driver) {
$this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
}
$this->pdo->beginTransaction();
}
$this->inTransaction = true;
}
}
/**
* Helper method to commit a transaction.
*/
private function commit(): void
{
if ($this->inTransaction) {
try {
// commit read-write transaction which also releases the lock
if ('sqlite' === $this->driver) {
$this->pdo->exec('COMMIT');
} else {
$this->pdo->commit();
}
$this->inTransaction = false;
} catch (\PDOException $e) {
$this->rollback();
throw $e;
}
}
}
/**
* Helper method to rollback a transaction.
*/
private function rollback(): void
{
// We only need to rollback if we are in a transaction. Otherwise the resulting
// error would hide the real problem why rollback was called. We might not be
// in a transaction when not using the transactional locking behavior or when
// two callbacks (e.g. destroy and write) are invoked that both fail.
if ($this->inTransaction) {
if ('sqlite' === $this->driver) {
$this->pdo->exec('ROLLBACK');
} else {
$this->pdo->rollBack();
}
$this->inTransaction = false;
}
}
/**
* Reads the session data in respect to the different locking strategies.
*
* We need to make sure we do not return session data that is already considered garbage according
* to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
*/
protected function doRead(#[\SensitiveParameter] string $sessionId): string
{
if (self::LOCK_ADVISORY === $this->lockMode) {
$this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
}
$selectSql = $this->getSelectSql();
$selectStmt = $this->pdo->prepare($selectSql);
$selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$insertStmt = null;
while (true) {
$selectStmt->execute();
$sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
if ($sessionRows) {
$expiry = (int) $sessionRows[0][1];
if ($expiry < time()) {
$this->sessionExpired = true;
return '';
}
return \is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
}
if (null !== $insertStmt) {
$this->rollback();
throw new \RuntimeException('Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.');
}
if (!filter_var(\ini_get('session.use_strict_mode'), \FILTER_VALIDATE_BOOL) && self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
// In strict mode, session fixation is not possible: new sessions always start with a unique
// random id, so that concurrency is not possible and this code path can be skipped.
// Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
// until other connections to the session are committed.
try {
$insertStmt = $this->getInsertStatement($sessionId, '', 0);
$insertStmt->execute();
} catch (\PDOException $e) {
// Catch duplicate key error because other connection created the session already.
// It would only not be the case when the other connection destroyed the session.
if (str_starts_with($e->getCode(), '23')) {
// Retrieve finished session data written by concurrent connection by restarting the loop.
// We have to start a new transaction as a failed query will mark the current transaction as
// aborted in PostgreSQL and disallow further queries within it.
$this->rollback();
$this->beginTransaction();
continue;
}
throw $e;
}
}
return '';
}
}
/**
* Executes an application-level lock on the database.
*
* @return \PDOStatement The statement that needs to be executed later to release the lock
*
* @throws \DomainException When an unsupported PDO driver is used
*
* @todo implement missing advisory locks
* - for oci using DBMS_LOCK.REQUEST
* - for sqlsrv using sp_getapplock with LockOwner = Session
*/
private function doAdvisoryLock(#[\SensitiveParameter] string $sessionId): \PDOStatement
{
switch ($this->driver) {
case 'mysql':
// MySQL 5.7.5 and later enforces a maximum length on lock names of 64 characters. Previously, no limit was enforced.
$lockId = substr($sessionId, 0, 64);
// should we handle the return value? 0 on timeout, null on error
// we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
$stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
$stmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
$stmt->execute();
$releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
$releaseStmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
return $releaseStmt;
case 'pgsql':
// Obtaining an exclusive session level advisory lock requires an integer key.
// When session.sid_bits_per_character > 4, the session id can contain non-hex-characters.
// So we cannot just use hexdec().
if (4 === \PHP_INT_SIZE) {
$sessionInt1 = $this->convertStringToInt($sessionId);
$sessionInt2 = $this->convertStringToInt(substr($sessionId, 4, 4));
$stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
$stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
$stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
$stmt->execute();
$releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
$releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
$releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
} else {
$sessionBigInt = $this->convertStringToInt($sessionId);
$stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
$stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
$stmt->execute();
$releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
$releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
}
return $releaseStmt;
case 'sqlite':
throw new \DomainException('SQLite does not support advisory locks.');
default:
throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
}
}
/**
* Encodes the first 4 (when PHP_INT_SIZE == 4) or 8 characters of the string as an integer.
*
* Keep in mind, PHP integers are signed.
*/
private function convertStringToInt(string $string): int
{
if (4 === \PHP_INT_SIZE) {
return (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
}
$int1 = (\ord($string[7]) << 24) + (\ord($string[6]) << 16) + (\ord($string[5]) << 8) + \ord($string[4]);
$int2 = (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
return $int2 + ($int1 << 32);
}
/**
* Return a locking or nonlocking SQL query to read session information.
*
* @throws \DomainException When an unsupported PDO driver is used
*/
private function getSelectSql(): string
{
if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
$this->beginTransaction();
switch ($this->driver) {
case 'mysql':
case 'oci':
case 'pgsql':
return "SELECT $this->dataCol, $this->lifetimeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
case 'sqlsrv':
return "SELECT $this->dataCol, $this->lifetimeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
case 'sqlite':
// we already locked when starting transaction
break;
default:
throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
}
}
return "SELECT $this->dataCol, $this->lifetimeCol FROM $this->table WHERE $this->idCol = :id";
}
/**
* Returns an insert statement supported by the database for writing session data.
*/
private function getInsertStatement(#[\SensitiveParameter] string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement
{
switch ($this->driver) {
case 'oci':
$data = fopen('php://memory', 'r+');
fwrite($data, $sessionData);
rewind($data);
$sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), :expiry, :time) RETURNING $this->dataCol into :data";
break;
default:
$data = $sessionData;
$sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
break;
}
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
$stmt->bindValue(':expiry', time() + $maxlifetime, \PDO::PARAM_INT);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
return $stmt;
}
/**
* Returns an update statement supported by the database for writing session data.
*/
private function getUpdateStatement(#[\SensitiveParameter] string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement
{
switch ($this->driver) {
case 'oci':
$data = fopen('php://memory', 'r+');
fwrite($data, $sessionData);
rewind($data);
$sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data";
break;
default:
$data = $sessionData;
$sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id";
break;
}
$stmt = $this->pdo->prepare($sql);
$stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
$stmt->bindValue(':expiry', time() + $maxlifetime, \PDO::PARAM_INT);
$stmt->bindValue(':time', time(), \PDO::PARAM_INT);
return $stmt;
}
/**
* Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data.
*/
private function getMergeStatement(#[\SensitiveParameter] string $sessionId, string $data, int $maxlifetime): ?\PDOStatement
{
switch (true) {
case 'mysql' === $this->driver:
$mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time) ".
"ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
break;
case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
// MERGE is only available since SQL Server 2008 and must be terminated by semicolon
// It also requires HOLDLOCK according to https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/
$mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
"WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
"WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
break;
case 'sqlite' === $this->driver:
$mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time)";
break;
case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='):
$mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :expiry, :time) ".
"ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
break;
default:
// MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html
return null;
}
$mergeStmt = $this->pdo->prepare($mergeSql);
if ('sqlsrv' === $this->driver) {
$mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB);
$mergeStmt->bindValue(4, time() + $maxlifetime, \PDO::PARAM_INT);
$mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
$mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB);
$mergeStmt->bindValue(7, time() + $maxlifetime, \PDO::PARAM_INT);
$mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
} else {
$mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
$mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
$mergeStmt->bindValue(':expiry', time() + $maxlifetime, \PDO::PARAM_INT);
$mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
}
return $mergeStmt;
}
/**
* Return a PDO instance.
*/
protected function getConnection(): \PDO
{
if (!isset($this->pdo)) {
$this->connect($this->dsn ?: \ini_get('session.save_path'));
}
return $this->pdo;
}
}
@@ -0,0 +1,103 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use Predis\Response\ErrorInterface;
use Relay\Relay;
/**
* Redis based session storage handler based on the Redis class
* provided by the PHP redis extension.
*
* @author Dalibor Karlović <dalibor@flexolabs.io>
*/
class RedisSessionHandler extends AbstractSessionHandler
{
/**
* Key prefix for shared environments.
*/
private string $prefix;
/**
* Time to live in seconds.
*/
private int|\Closure|null $ttl;
/**
* List of available options:
* * prefix: The prefix to use for the keys in order to avoid collision on the Redis server
* * ttl: The time to live in seconds.
*
* @throws \InvalidArgumentException When unsupported client or options are passed
*/
public function __construct(
private \Redis|Relay|\RedisArray|\RedisCluster|\Predis\ClientInterface $redis,
array $options = [],
) {
if ($diff = array_diff(array_keys($options), ['prefix', 'ttl'])) {
throw new \InvalidArgumentException(sprintf('The following options are not supported "%s".', implode(', ', $diff)));
}
$this->prefix = $options['prefix'] ?? 'sf_s';
$this->ttl = $options['ttl'] ?? null;
}
protected function doRead(#[\SensitiveParameter] string $sessionId): string
{
return $this->redis->get($this->prefix.$sessionId) ?: '';
}
protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime');
$result = $this->redis->setEx($this->prefix.$sessionId, (int) $ttl, $data);
return $result && !$result instanceof ErrorInterface;
}
protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
{
static $unlink = true;
if ($unlink) {
try {
$unlink = false !== $this->redis->unlink($this->prefix.$sessionId);
} catch (\Throwable) {
$unlink = false;
}
}
if (!$unlink) {
$this->redis->del($this->prefix.$sessionId);
}
return true;
}
#[\ReturnTypeWillChange]
public function close(): bool
{
return true;
}
public function gc(int $maxlifetime): int|false
{
return 0;
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
$ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime');
return $this->redis->expire($this->prefix.$sessionId, (int) $ttl);
}
}
@@ -0,0 +1,99 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory;
use Doctrine\DBAL\Tools\DsnParser;
use Relay\Relay;
use Symfony\Component\Cache\Adapter\AbstractAdapter;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class SessionHandlerFactory
{
public static function createHandler(object|string $connection, array $options = []): AbstractSessionHandler
{
if ($query = \is_string($connection) ? parse_url($connection) : false) {
parse_str($query['query'] ?? '', $query);
if (($options['ttl'] ?? null) instanceof \Closure) {
$query['ttl'] = $options['ttl'];
}
}
$options = ($query ?: []) + $options;
switch (true) {
case $connection instanceof \Redis:
case $connection instanceof Relay:
case $connection instanceof \RedisArray:
case $connection instanceof \RedisCluster:
case $connection instanceof \Predis\ClientInterface:
return new RedisSessionHandler($connection);
case $connection instanceof \Memcached:
return new MemcachedSessionHandler($connection);
case $connection instanceof \PDO:
return new PdoSessionHandler($connection);
case !\is_string($connection):
throw new \InvalidArgumentException(sprintf('Unsupported Connection: "%s".', get_debug_type($connection)));
case str_starts_with($connection, 'file://'):
$savePath = substr($connection, 7);
return new StrictSessionHandler(new NativeFileSessionHandler('' === $savePath ? null : $savePath));
case str_starts_with($connection, 'redis:'):
case str_starts_with($connection, 'rediss:'):
case str_starts_with($connection, 'memcached:'):
if (!class_exists(AbstractAdapter::class)) {
throw new \InvalidArgumentException('Unsupported Redis or Memcached DSN. Try running "composer require symfony/cache".');
}
$handlerClass = str_starts_with($connection, 'memcached:') ? MemcachedSessionHandler::class : RedisSessionHandler::class;
$connection = AbstractAdapter::createConnection($connection, ['lazy' => true]);
return new $handlerClass($connection, array_intersect_key($options, ['prefix' => 1, 'ttl' => 1]));
case str_starts_with($connection, 'pdo_oci://'):
if (!class_exists(DriverManager::class)) {
throw new \InvalidArgumentException('Unsupported PDO OCI DSN. Try running "composer require doctrine/dbal".');
}
$connection[3] = '-';
$params = class_exists(DsnParser::class) ? (new DsnParser())->parse($connection) : ['url' => $connection];
$config = new Configuration();
if (class_exists(DefaultSchemaManagerFactory::class)) {
$config->setSchemaManagerFactory(new DefaultSchemaManagerFactory());
}
$connection = DriverManager::getConnection($params, $config);
// The condition should be removed once support for DBAL <3.3 is dropped
$connection = method_exists($connection, 'getNativeConnection') ? $connection->getNativeConnection() : $connection->getWrappedConnection();
// no break;
case str_starts_with($connection, 'mssql://'):
case str_starts_with($connection, 'mysql://'):
case str_starts_with($connection, 'mysql2://'):
case str_starts_with($connection, 'pgsql://'):
case str_starts_with($connection, 'postgres://'):
case str_starts_with($connection, 'postgresql://'):
case str_starts_with($connection, 'sqlsrv://'):
case str_starts_with($connection, 'sqlite://'):
case str_starts_with($connection, 'sqlite3://'):
return new PdoSessionHandler($connection, $options);
}
throw new \InvalidArgumentException(sprintf('Unsupported Connection: "%s".', $connection));
}
}
@@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
/**
* Adds basic `SessionUpdateTimestampHandlerInterface` behaviors to another `SessionHandlerInterface`.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class StrictSessionHandler extends AbstractSessionHandler
{
private \SessionHandlerInterface $handler;
private bool $doDestroy;
public function __construct(\SessionHandlerInterface $handler)
{
if ($handler instanceof \SessionUpdateTimestampHandlerInterface) {
throw new \LogicException(sprintf('"%s" is already an instance of "SessionUpdateTimestampHandlerInterface", you cannot wrap it with "%s".', get_debug_type($handler), self::class));
}
$this->handler = $handler;
}
/**
* Returns true if this handler wraps an internal PHP session save handler using \SessionHandler.
*
* @internal
*/
public function isWrapper(): bool
{
return $this->handler instanceof \SessionHandler;
}
public function open(string $savePath, string $sessionName): bool
{
parent::open($savePath, $sessionName);
return $this->handler->open($savePath, $sessionName);
}
protected function doRead(#[\SensitiveParameter] string $sessionId): string
{
return $this->handler->read($sessionId);
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return $this->write($sessionId, $data);
}
protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return $this->handler->write($sessionId, $data);
}
public function destroy(#[\SensitiveParameter] string $sessionId): bool
{
$this->doDestroy = true;
$destroyed = parent::destroy($sessionId);
return $this->doDestroy ? $this->doDestroy($sessionId) : $destroyed;
}
protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool
{
$this->doDestroy = false;
return $this->handler->destroy($sessionId);
}
public function close(): bool
{
return $this->handler->close();
}
public function gc(int $maxlifetime): int|false
{
return $this->handler->gc($maxlifetime);
}
}
@@ -0,0 +1,148 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Session\SessionBagInterface;
/**
* Metadata container.
*
* Adds metadata to the session.
*
* @author Drak <drak@zikula.org>
*/
class MetadataBag implements SessionBagInterface
{
public const CREATED = 'c';
public const UPDATED = 'u';
public const LIFETIME = 'l';
private string $name = '__metadata';
private string $storageKey;
/**
* @var array
*/
protected $meta = [self::CREATED => 0, self::UPDATED => 0, self::LIFETIME => 0];
/**
* Unix timestamp.
*/
private int $lastUsed;
private int $updateThreshold;
/**
* @param string $storageKey The key used to store bag in the session
* @param int $updateThreshold The time to wait between two UPDATED updates
*/
public function __construct(string $storageKey = '_sf2_meta', int $updateThreshold = 0)
{
$this->storageKey = $storageKey;
$this->updateThreshold = $updateThreshold;
}
/**
* @return void
*/
public function initialize(array &$array)
{
$this->meta = &$array;
if (isset($array[self::CREATED])) {
$this->lastUsed = $this->meta[self::UPDATED];
$timeStamp = time();
if ($timeStamp - $array[self::UPDATED] >= $this->updateThreshold) {
$this->meta[self::UPDATED] = $timeStamp;
}
} else {
$this->stampCreated();
}
}
/**
* Gets the lifetime that the session cookie was set with.
*/
public function getLifetime(): int
{
return $this->meta[self::LIFETIME];
}
/**
* Stamps a new session's metadata.
*
* @param int|null $lifetime Sets the cookie lifetime for the session cookie. A null value
* will leave the system settings unchanged, 0 sets the cookie
* to expire with browser session. Time is in seconds, and is
* not a Unix timestamp.
*
* @return void
*/
public function stampNew(?int $lifetime = null)
{
$this->stampCreated($lifetime);
}
public function getStorageKey(): string
{
return $this->storageKey;
}
/**
* Gets the created timestamp metadata.
*
* @return int Unix timestamp
*/
public function getCreated(): int
{
return $this->meta[self::CREATED];
}
/**
* Gets the last used metadata.
*
* @return int Unix timestamp
*/
public function getLastUsed(): int
{
return $this->lastUsed;
}
public function clear(): mixed
{
// nothing to do
return null;
}
public function getName(): string
{
return $this->name;
}
/**
* Sets name.
*
* @return void
*/
public function setName(string $name)
{
$this->name = $name;
}
private function stampCreated(?int $lifetime = null): void
{
$timeStamp = time();
$this->meta[self::CREATED] = $this->meta[self::UPDATED] = $this->lastUsed = $timeStamp;
$this->meta[self::LIFETIME] = $lifetime ?? (int) \ini_get('session.cookie_lifetime');
}
}
@@ -0,0 +1,238 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Session\SessionBagInterface;
/**
* MockArraySessionStorage mocks the session for unit tests.
*
* No PHP session is actually started since a session can be initialized
* and shutdown only once per PHP execution cycle.
*
* When doing functional testing, you should use MockFileSessionStorage instead.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
* @author Drak <drak@zikula.org>
*/
class MockArraySessionStorage implements SessionStorageInterface
{
/**
* @var string
*/
protected $id = '';
/**
* @var string
*/
protected $name;
/**
* @var bool
*/
protected $started = false;
/**
* @var bool
*/
protected $closed = false;
/**
* @var array
*/
protected $data = [];
/**
* @var MetadataBag
*/
protected $metadataBag;
/**
* @var array|SessionBagInterface[]
*/
protected $bags = [];
public function __construct(string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null)
{
$this->name = $name;
$this->setMetadataBag($metaBag);
}
/**
* @return void
*/
public function setSessionData(array $array)
{
$this->data = $array;
}
public function start(): bool
{
if ($this->started) {
return true;
}
if (empty($this->id)) {
$this->id = $this->generateId();
}
$this->loadSession();
return true;
}
public function regenerate(bool $destroy = false, ?int $lifetime = null): bool
{
if (!$this->started) {
$this->start();
}
$this->metadataBag->stampNew($lifetime);
$this->id = $this->generateId();
return true;
}
public function getId(): string
{
return $this->id;
}
/**
* @return void
*/
public function setId(string $id)
{
if ($this->started) {
throw new \LogicException('Cannot set session ID after the session has started.');
}
$this->id = $id;
}
public function getName(): string
{
return $this->name;
}
/**
* @return void
*/
public function setName(string $name)
{
$this->name = $name;
}
/**
* @return void
*/
public function save()
{
if (!$this->started || $this->closed) {
throw new \RuntimeException('Trying to save a session that was not started yet or was already closed.');
}
// nothing to do since we don't persist the session data
$this->closed = false;
$this->started = false;
}
/**
* @return void
*/
public function clear()
{
// clear out the bags
foreach ($this->bags as $bag) {
$bag->clear();
}
// clear out the session
$this->data = [];
// reconnect the bags to the session
$this->loadSession();
}
/**
* @return void
*/
public function registerBag(SessionBagInterface $bag)
{
$this->bags[$bag->getName()] = $bag;
}
public function getBag(string $name): SessionBagInterface
{
if (!isset($this->bags[$name])) {
throw new \InvalidArgumentException(sprintf('The SessionBagInterface "%s" is not registered.', $name));
}
if (!$this->started) {
$this->start();
}
return $this->bags[$name];
}
public function isStarted(): bool
{
return $this->started;
}
/**
* @return void
*/
public function setMetadataBag(?MetadataBag $bag = null)
{
if (1 > \func_num_args()) {
trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__);
}
$this->metadataBag = $bag ?? new MetadataBag();
}
/**
* Gets the MetadataBag.
*/
public function getMetadataBag(): MetadataBag
{
return $this->metadataBag;
}
/**
* Generates a session ID.
*
* This doesn't need to be particularly cryptographically secure since this is just
* a mock.
*/
protected function generateId(): string
{
return hash('sha256', uniqid('ss_mock_', true));
}
/**
* @return void
*/
protected function loadSession()
{
$bags = array_merge($this->bags, [$this->metadataBag]);
foreach ($bags as $bag) {
$key = $bag->getStorageKey();
$this->data[$key] ??= [];
$bag->initialize($this->data[$key]);
}
$this->started = true;
$this->closed = false;
}
}
@@ -0,0 +1,152 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
/**
* MockFileSessionStorage is used to mock sessions for
* functional testing where you may need to persist session data
* across separate PHP processes.
*
* No PHP session is actually started since a session can be initialized
* and shutdown only once per PHP execution cycle and this class does
* not pollute any session related globals, including session_*() functions
* or session.* PHP ini directives.
*
* @author Drak <drak@zikula.org>
*/
class MockFileSessionStorage extends MockArraySessionStorage
{
private string $savePath;
/**
* @param string|null $savePath Path of directory to save session files
*/
public function __construct(?string $savePath = null, string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null)
{
$savePath ??= sys_get_temp_dir();
if (!is_dir($savePath) && !@mkdir($savePath, 0777, true) && !is_dir($savePath)) {
throw new \RuntimeException(sprintf('Session Storage was not able to create directory "%s".', $savePath));
}
$this->savePath = $savePath;
parent::__construct($name, $metaBag);
}
public function start(): bool
{
if ($this->started) {
return true;
}
if (!$this->id) {
$this->id = $this->generateId();
}
$this->read();
$this->started = true;
return true;
}
public function regenerate(bool $destroy = false, ?int $lifetime = null): bool
{
if (!$this->started) {
$this->start();
}
if ($destroy) {
$this->destroy();
}
return parent::regenerate($destroy, $lifetime);
}
/**
* @return void
*/
public function save()
{
if (!$this->started) {
throw new \RuntimeException('Trying to save a session that was not started yet or was already closed.');
}
$data = $this->data;
foreach ($this->bags as $bag) {
if (empty($data[$key = $bag->getStorageKey()])) {
unset($data[$key]);
}
}
if ([$key = $this->metadataBag->getStorageKey()] === array_keys($data)) {
unset($data[$key]);
}
try {
if ($data) {
$path = $this->getFilePath();
$tmp = $path.bin2hex(random_bytes(6));
file_put_contents($tmp, serialize($data));
rename($tmp, $path);
} else {
$this->destroy();
}
} finally {
$this->data = $data;
}
// this is needed when the session object is re-used across multiple requests
// in functional tests.
$this->started = false;
}
/**
* Deletes a session from persistent storage.
* Deliberately leaves session data in memory intact.
*/
private function destroy(): void
{
set_error_handler(static function () {});
try {
unlink($this->getFilePath());
} finally {
restore_error_handler();
}
}
/**
* Calculate path to file.
*/
private function getFilePath(): string
{
return $this->savePath.'/'.$this->id.'.mocksess';
}
/**
* Reads session from storage and loads session.
*/
private function read(): void
{
set_error_handler(static function () {});
try {
$data = file_get_contents($this->getFilePath());
} finally {
restore_error_handler();
}
$this->data = $data ? unserialize($data) : [];
$this->loadSession();
}
}
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Request;
// Help opcache.preload discover always-needed symbols
class_exists(MockFileSessionStorage::class);
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class MockFileSessionStorageFactory implements SessionStorageFactoryInterface
{
private ?string $savePath;
private string $name;
private ?MetadataBag $metaBag;
/**
* @see MockFileSessionStorage constructor.
*/
public function __construct(?string $savePath = null, string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null)
{
$this->savePath = $savePath;
$this->name = $name;
$this->metaBag = $metaBag;
}
public function createStorage(?Request $request): SessionStorageInterface
{
return new MockFileSessionStorage($this->savePath, $this->name, $this->metaBag);
}
}
@@ -0,0 +1,449 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Session\SessionBagInterface;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\SessionHandlerProxy;
// Help opcache.preload discover always-needed symbols
class_exists(MetadataBag::class);
class_exists(StrictSessionHandler::class);
class_exists(SessionHandlerProxy::class);
/**
* This provides a base class for session attribute storage.
*
* @author Drak <drak@zikula.org>
*/
class NativeSessionStorage implements SessionStorageInterface
{
/**
* @var SessionBagInterface[]
*/
protected $bags = [];
/**
* @var bool
*/
protected $started = false;
/**
* @var bool
*/
protected $closed = false;
/**
* @var AbstractProxy|\SessionHandlerInterface
*/
protected $saveHandler;
/**
* @var MetadataBag
*/
protected $metadataBag;
/**
* Depending on how you want the storage driver to behave you probably
* want to override this constructor entirely.
*
* List of options for $options array with their defaults.
*
* @see https://php.net/session.configuration for options
* but we omit 'session.' from the beginning of the keys for convenience.
*
* ("auto_start", is not supported as it tells PHP to start a session before
* PHP starts to execute user-land code. Setting during runtime has no effect).
*
* cache_limiter, "" (use "0" to prevent headers from being sent entirely).
* cache_expire, "0"
* cookie_domain, ""
* cookie_httponly, ""
* cookie_lifetime, "0"
* cookie_path, "/"
* cookie_secure, ""
* cookie_samesite, null
* gc_divisor, "100"
* gc_maxlifetime, "1440"
* gc_probability, "1"
* lazy_write, "1"
* name, "PHPSESSID"
* referer_check, ""
* serialize_handler, "php"
* use_strict_mode, "1"
* use_cookies, "1"
* use_only_cookies, "1"
* use_trans_sid, "0"
* sid_length, "32"
* sid_bits_per_character, "5"
* trans_sid_hosts, $_SERVER['HTTP_HOST']
* trans_sid_tags, "a=href,area=href,frame=src,form="
*/
public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null)
{
if (!\extension_loaded('session')) {
throw new \LogicException('PHP extension "session" is required.');
}
$options += [
'cache_limiter' => '',
'cache_expire' => 0,
'use_cookies' => 1,
'lazy_write' => 1,
'use_strict_mode' => 1,
];
session_register_shutdown();
$this->setMetadataBag($metaBag);
$this->setOptions($options);
$this->setSaveHandler($handler);
}
/**
* Gets the save handler instance.
*/
public function getSaveHandler(): AbstractProxy|\SessionHandlerInterface
{
return $this->saveHandler;
}
public function start(): bool
{
if ($this->started) {
return true;
}
if (\PHP_SESSION_ACTIVE === session_status()) {
throw new \RuntimeException('Failed to start the session: already started by PHP.');
}
if (filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL) && headers_sent($file, $line)) {
throw new \RuntimeException(sprintf('Failed to start the session because headers have already been sent by "%s" at line %d.', $file, $line));
}
$sessionId = $_COOKIE[session_name()] ?? null;
/*
* Explanation of the session ID regular expression: `/^[a-zA-Z0-9,-]{22,250}$/`.
*
* ---------- Part 1
*
* The part `[a-zA-Z0-9,-]` is related to the PHP ini directive `session.sid_bits_per_character` defined as 6.
* See https://www.php.net/manual/en/session.configuration.php#ini.session.sid-bits-per-character.
* Allowed values are integers such as:
* - 4 for range `a-f0-9`
* - 5 for range `a-v0-9`
* - 6 for range `a-zA-Z0-9,-`
*
* ---------- Part 2
*
* The part `{22,250}` is related to the PHP ini directive `session.sid_length`.
* See https://www.php.net/manual/en/session.configuration.php#ini.session.sid-length.
* Allowed values are integers between 22 and 256, but we use 250 for the max.
*
* Where does the 250 come from?
* - The length of Windows and Linux filenames is limited to 255 bytes. Then the max must not exceed 255.
* - The session filename prefix is `sess_`, a 5 bytes string. Then the max must not exceed 255 - 5 = 250.
*
* ---------- Conclusion
*
* The parts 1 and 2 prevent the warning below:
* `PHP Warning: SessionHandler::read(): Session ID is too long or contains illegal characters. Only the A-Z, a-z, 0-9, "-", and "," characters are allowed.`
*
* The part 2 prevents the warning below:
* `PHP Warning: SessionHandler::read(): open(filepath, O_RDWR) failed: No such file or directory (2).`
*/
if ($sessionId && $this->saveHandler instanceof AbstractProxy && 'files' === $this->saveHandler->getSaveHandlerName() && !preg_match('/^[a-zA-Z0-9,-]{22,250}$/', $sessionId)) {
// the session ID in the header is invalid, create a new one
session_id(session_create_id());
}
// ok to try and start the session
if (!session_start()) {
throw new \RuntimeException('Failed to start the session.');
}
$this->loadSession();
return true;
}
public function getId(): string
{
return $this->saveHandler->getId();
}
/**
* @return void
*/
public function setId(string $id)
{
$this->saveHandler->setId($id);
}
public function getName(): string
{
return $this->saveHandler->getName();
}
/**
* @return void
*/
public function setName(string $name)
{
$this->saveHandler->setName($name);
}
public function regenerate(bool $destroy = false, ?int $lifetime = null): bool
{
// Cannot regenerate the session ID for non-active sessions.
if (\PHP_SESSION_ACTIVE !== session_status()) {
return false;
}
if (headers_sent()) {
return false;
}
if (null !== $lifetime && $lifetime != \ini_get('session.cookie_lifetime')) {
$this->save();
ini_set('session.cookie_lifetime', $lifetime);
$this->start();
}
if ($destroy) {
$this->metadataBag->stampNew();
}
return session_regenerate_id($destroy);
}
/**
* @return void
*/
public function save()
{
// Store a copy so we can restore the bags in case the session was not left empty
$session = $_SESSION;
foreach ($this->bags as $bag) {
if (empty($_SESSION[$key = $bag->getStorageKey()])) {
unset($_SESSION[$key]);
}
}
if ($_SESSION && [$key = $this->metadataBag->getStorageKey()] === array_keys($_SESSION)) {
unset($_SESSION[$key]);
}
// Register error handler to add information about the current save handler
$previousHandler = set_error_handler(function ($type, $msg, $file, $line) use (&$previousHandler) {
if (\E_WARNING === $type && str_starts_with($msg, 'session_write_close():')) {
$handler = $this->saveHandler instanceof SessionHandlerProxy ? $this->saveHandler->getHandler() : $this->saveHandler;
$msg = sprintf('session_write_close(): Failed to write session data with "%s" handler', $handler::class);
}
return $previousHandler ? $previousHandler($type, $msg, $file, $line) : false;
});
try {
session_write_close();
} finally {
restore_error_handler();
// Restore only if not empty
if ($_SESSION) {
$_SESSION = $session;
}
}
$this->closed = true;
$this->started = false;
}
/**
* @return void
*/
public function clear()
{
// clear out the bags
foreach ($this->bags as $bag) {
$bag->clear();
}
// clear out the session
$_SESSION = [];
// reconnect the bags to the session
$this->loadSession();
}
/**
* @return void
*/
public function registerBag(SessionBagInterface $bag)
{
if ($this->started) {
throw new \LogicException('Cannot register a bag when the session is already started.');
}
$this->bags[$bag->getName()] = $bag;
}
public function getBag(string $name): SessionBagInterface
{
if (!isset($this->bags[$name])) {
throw new \InvalidArgumentException(sprintf('The SessionBagInterface "%s" is not registered.', $name));
}
if (!$this->started && $this->saveHandler->isActive()) {
$this->loadSession();
} elseif (!$this->started) {
$this->start();
}
return $this->bags[$name];
}
/**
* @return void
*/
public function setMetadataBag(?MetadataBag $metaBag = null)
{
if (1 > \func_num_args()) {
trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__);
}
$this->metadataBag = $metaBag ?? new MetadataBag();
}
/**
* Gets the MetadataBag.
*/
public function getMetadataBag(): MetadataBag
{
return $this->metadataBag;
}
public function isStarted(): bool
{
return $this->started;
}
/**
* Sets session.* ini variables.
*
* For convenience we omit 'session.' from the beginning of the keys.
* Explicitly ignores other ini keys.
*
* @param array $options Session ini directives [key => value]
*
* @see https://php.net/session.configuration
*
* @return void
*/
public function setOptions(array $options)
{
if (headers_sent() || \PHP_SESSION_ACTIVE === session_status()) {
return;
}
$validOptions = array_flip([
'cache_expire', 'cache_limiter', 'cookie_domain', 'cookie_httponly',
'cookie_lifetime', 'cookie_path', 'cookie_secure', 'cookie_samesite',
'gc_divisor', 'gc_maxlifetime', 'gc_probability',
'lazy_write', 'name', 'referer_check',
'serialize_handler', 'use_strict_mode', 'use_cookies',
'use_only_cookies', 'use_trans_sid',
'sid_length', 'sid_bits_per_character', 'trans_sid_hosts', 'trans_sid_tags',
]);
foreach ($options as $key => $value) {
if (isset($validOptions[$key])) {
if ('cookie_secure' === $key && 'auto' === $value) {
continue;
}
ini_set('session.'.$key, $value);
}
}
}
/**
* Registers session save handler as a PHP session handler.
*
* To use internal PHP session save handlers, override this method using ini_set with
* session.save_handler and session.save_path e.g.
*
* ini_set('session.save_handler', 'files');
* ini_set('session.save_path', '/tmp');
*
* or pass in a \SessionHandler instance which configures session.save_handler in the
* constructor, for a template see NativeFileSessionHandler.
*
* @see https://php.net/session-set-save-handler
* @see https://php.net/sessionhandlerinterface
* @see https://php.net/sessionhandler
*
* @return void
*
* @throws \InvalidArgumentException
*/
public function setSaveHandler(AbstractProxy|\SessionHandlerInterface|null $saveHandler = null)
{
if (1 > \func_num_args()) {
trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__);
}
// Wrap $saveHandler in proxy and prevent double wrapping of proxy
if (!$saveHandler instanceof AbstractProxy && $saveHandler instanceof \SessionHandlerInterface) {
$saveHandler = new SessionHandlerProxy($saveHandler);
} elseif (!$saveHandler instanceof AbstractProxy) {
$saveHandler = new SessionHandlerProxy(new StrictSessionHandler(new \SessionHandler()));
}
$this->saveHandler = $saveHandler;
if (headers_sent() || \PHP_SESSION_ACTIVE === session_status()) {
return;
}
if ($this->saveHandler instanceof SessionHandlerProxy) {
session_set_save_handler($this->saveHandler, false);
}
}
/**
* Load the session with attributes.
*
* After starting the session, PHP retrieves the session from whatever handlers
* are set to (either PHP's internal, or a custom save handler set with session_set_save_handler()).
* PHP takes the return value from the read() handler, unserializes it
* and populates $_SESSION with the result automatically.
*
* @return void
*/
protected function loadSession(?array &$session = null)
{
if (null === $session) {
$session = &$_SESSION;
}
$bags = array_merge($this->bags, [$this->metadataBag]);
foreach ($bags as $bag) {
$key = $bag->getStorageKey();
$session[$key] = isset($session[$key]) && \is_array($session[$key]) ? $session[$key] : [];
$bag->initialize($session[$key]);
}
$this->started = true;
$this->closed = false;
}
}
@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
// Help opcache.preload discover always-needed symbols
class_exists(NativeSessionStorage::class);
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class NativeSessionStorageFactory implements SessionStorageFactoryInterface
{
private array $options;
private AbstractProxy|\SessionHandlerInterface|null $handler;
private ?MetadataBag $metaBag;
private bool $secure;
/**
* @see NativeSessionStorage constructor.
*/
public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null, bool $secure = false)
{
$this->options = $options;
$this->handler = $handler;
$this->metaBag = $metaBag;
$this->secure = $secure;
}
public function createStorage(?Request $request): SessionStorageInterface
{
$storage = new NativeSessionStorage($this->options, $this->handler, $this->metaBag);
if ($this->secure && $request?->isSecure()) {
$storage->setOptions(['cookie_secure' => true]);
}
return $storage;
}
}
@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
/**
* Allows session to be started by PHP and managed by Symfony.
*
* @author Drak <drak@zikula.org>
*/
class PhpBridgeSessionStorage extends NativeSessionStorage
{
public function __construct(AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null)
{
if (!\extension_loaded('session')) {
throw new \LogicException('PHP extension "session" is required.');
}
$this->setMetadataBag($metaBag);
$this->setSaveHandler($handler);
}
public function start(): bool
{
if ($this->started) {
return true;
}
$this->loadSession();
return true;
}
/**
* @return void
*/
public function clear()
{
// clear out the bags and nothing else that may be set
// since the purpose of this driver is to share a handler
foreach ($this->bags as $bag) {
$bag->clear();
}
// reconnect the bags to the session
$this->loadSession();
}
}
@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy;
// Help opcache.preload discover always-needed symbols
class_exists(PhpBridgeSessionStorage::class);
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class PhpBridgeSessionStorageFactory implements SessionStorageFactoryInterface
{
private AbstractProxy|\SessionHandlerInterface|null $handler;
private ?MetadataBag $metaBag;
private bool $secure;
public function __construct(AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null, bool $secure = false)
{
$this->handler = $handler;
$this->metaBag = $metaBag;
$this->secure = $secure;
}
public function createStorage(?Request $request): SessionStorageInterface
{
$storage = new PhpBridgeSessionStorage($this->handler, $this->metaBag);
if ($this->secure && $request?->isSecure()) {
$storage->setOptions(['cookie_secure' => true]);
}
return $storage;
}
}
@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Proxy;
/**
* @author Drak <drak@zikula.org>
*/
abstract class AbstractProxy
{
/**
* Flag if handler wraps an internal PHP session handler (using \SessionHandler).
*
* @var bool
*/
protected $wrapper = false;
/**
* @var string
*/
protected $saveHandlerName;
/**
* Gets the session.save_handler name.
*/
public function getSaveHandlerName(): ?string
{
return $this->saveHandlerName;
}
/**
* Is this proxy handler and instance of \SessionHandlerInterface.
*/
public function isSessionHandlerInterface(): bool
{
return $this instanceof \SessionHandlerInterface;
}
/**
* Returns true if this handler wraps an internal PHP session save handler using \SessionHandler.
*/
public function isWrapper(): bool
{
return $this->wrapper;
}
/**
* Has a session started?
*/
public function isActive(): bool
{
return \PHP_SESSION_ACTIVE === session_status();
}
/**
* Gets the session ID.
*/
public function getId(): string
{
return session_id();
}
/**
* Sets the session ID.
*
* @return void
*
* @throws \LogicException
*/
public function setId(string $id)
{
if ($this->isActive()) {
throw new \LogicException('Cannot change the ID of an active session.');
}
session_id($id);
}
/**
* Gets the session name.
*/
public function getName(): string
{
return session_name();
}
/**
* Sets the session name.
*
* @return void
*
* @throws \LogicException
*/
public function setName(string $name)
{
if ($this->isActive()) {
throw new \LogicException('Cannot change the name of an active session.');
}
session_name($name);
}
}
@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage\Proxy;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler;
/**
* @author Drak <drak@zikula.org>
*/
class SessionHandlerProxy extends AbstractProxy implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface
{
protected $handler;
public function __construct(\SessionHandlerInterface $handler)
{
$this->handler = $handler;
$this->wrapper = $handler instanceof \SessionHandler;
$this->saveHandlerName = $this->wrapper || ($handler instanceof StrictSessionHandler && $handler->isWrapper()) ? \ini_get('session.save_handler') : 'user';
}
public function getHandler(): \SessionHandlerInterface
{
return $this->handler;
}
// \SessionHandlerInterface
public function open(string $savePath, string $sessionName): bool
{
return $this->handler->open($savePath, $sessionName);
}
public function close(): bool
{
return $this->handler->close();
}
public function read(#[\SensitiveParameter] string $sessionId): string|false
{
return $this->handler->read($sessionId);
}
public function write(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return $this->handler->write($sessionId, $data);
}
public function destroy(#[\SensitiveParameter] string $sessionId): bool
{
return $this->handler->destroy($sessionId);
}
public function gc(int $maxlifetime): int|false
{
return $this->handler->gc($maxlifetime);
}
public function validateId(#[\SensitiveParameter] string $sessionId): bool
{
return !$this->handler instanceof \SessionUpdateTimestampHandlerInterface || $this->handler->validateId($sessionId);
}
public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool
{
return $this->handler instanceof \SessionUpdateTimestampHandlerInterface ? $this->handler->updateTimestamp($sessionId, $data) : $this->write($sessionId, $data);
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Request;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface SessionStorageFactoryInterface
{
/**
* Creates a new instance of SessionStorageInterface.
*/
public function createStorage(?Request $request): SessionStorageInterface;
}
@@ -0,0 +1,126 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Session\Storage;
use Symfony\Component\HttpFoundation\Session\SessionBagInterface;
/**
* StorageInterface.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Drak <drak@zikula.org>
*/
interface SessionStorageInterface
{
/**
* Starts the session.
*
* @throws \RuntimeException if something goes wrong starting the session
*/
public function start(): bool;
/**
* Checks if the session is started.
*/
public function isStarted(): bool;
/**
* Returns the session ID.
*/
public function getId(): string;
/**
* Sets the session ID.
*
* @return void
*/
public function setId(string $id);
/**
* Returns the session name.
*/
public function getName(): string;
/**
* Sets the session name.
*
* @return void
*/
public function setName(string $name);
/**
* Regenerates id that represents this storage.
*
* This method must invoke session_regenerate_id($destroy) unless
* this interface is used for a storage object designed for unit
* or functional testing where a real PHP session would interfere
* with testing.
*
* Note regenerate+destroy should not clear the session data in memory
* only delete the session data from persistent storage.
*
* Care: When regenerating the session ID no locking is involved in PHP's
* session design. See https://bugs.php.net/61470 for a discussion.
* So you must make sure the regenerated session is saved BEFORE sending the
* headers with the new ID. Symfony's HttpKernel offers a listener for this.
* See Symfony\Component\HttpKernel\EventListener\SaveSessionListener.
* 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.
*
* @param bool $destroy Destroy session when regenerating?
* @param int|null $lifetime Sets the cookie lifetime for the session cookie. A null value
* will leave the system settings unchanged, 0 sets the cookie
* to expire with browser session. Time is in seconds, and is
* not a Unix timestamp.
*
* @throws \RuntimeException If an error occurs while regenerating this storage
*/
public function regenerate(bool $destroy = false, ?int $lifetime = null): bool;
/**
* Force the session to be saved and closed.
*
* This method must invoke session_write_close() unless this interface is
* used for a storage object design for unit or functional testing where
* a real PHP session would interfere with testing, in which case
* it should actually persist the session data if required.
*
* @return void
*
* @throws \RuntimeException if the session is saved without being started, or if the session
* is already closed
*/
public function save();
/**
* Clear all session data in memory.
*
* @return void
*/
public function clear();
/**
* Gets a SessionBagInterface by name.
*
* @throws \InvalidArgumentException If the bag does not exist
*/
public function getBag(string $name): SessionBagInterface;
/**
* Registers a SessionBagInterface for use.
*
* @return void
*/
public function registerBag(SessionBagInterface $bag);
public function getMetadataBag(): MetadataBag;
}