Verzeichnisstruktur phpBB-3.3.16


Veröffentlicht
27.04.2026

So funktioniert es


Auf das letzte Element klicken. Dies geht jeweils ein Schritt zurück

Auf das Icon klicken, dies öffnet das Verzeichnis. Nochmal klicken schließt das Verzeichnis.
Auf den Verzeichnisnamen klicken, dies zeigt nur das Verzeichnis mit Inhalt an

      (Beispiel Datei-Icons)

Auf das Icon klicken um den Quellcode anzuzeigen

PdoSessionHandler.php

Zuletzt modifiziert: 01.05.2026, 11:28 - Dateigröße: 35.94 KiB


 001  <?php
 002   
 003  /*
 004   * This file is part of the Symfony package.
 005   *
 006   * (c) Fabien Potencier <fabien@symfony.com>
 007   *
 008   * For the full copyright and license information, please view the LICENSE
 009   * file that was distributed with this source code.
 010   */
 011   
 012  namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
 013   
 014  /**
 015   * Session handler using a PDO connection to read and write data.
 016   *
 017   * It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements
 018   * different locking strategies to handle concurrent access to the same session.
 019   * Locking is necessary to prevent loss of data due to race conditions and to keep
 020   * the session data consistent between read() and write(). With locking, requests
 021   * for the same session will wait until the other one finished writing. For this
 022   * reason it's best practice to close a session as early as possible to improve
 023   * concurrency. PHPs internal files session handler also implements locking.
 024   *
 025   * Attention: Since SQLite does not support row level locks but locks the whole database,
 026   * it means only one session can be accessed at a time. Even different sessions would wait
 027   * for another to finish. So saving session in SQLite should only be considered for
 028   * development or prototypes.
 029   *
 030   * Session data is a binary string that can contain non-printable characters like the null byte.
 031   * For this reason it must be saved in a binary column in the database like BLOB in MySQL.
 032   * Saving it in a character column could corrupt the data. You can use createTable()
 033   * to initialize a correctly defined table.
 034   *
 035   * @see https://php.net/sessionhandlerinterface
 036   *
 037   * @author Fabien Potencier <fabien@symfony.com>
 038   * @author Michael Williams <michael.williams@funsational.com>
 039   * @author Tobias Schultze <http://tobion.de>
 040   */
 041  class PdoSessionHandler extends AbstractSessionHandler
 042  {
 043      /**
 044       * No locking is done. This means sessions are prone to loss of data due to
 045       * race conditions of concurrent requests to the same session. The last session
 046       * write will win in this case. It might be useful when you implement your own
 047       * logic to deal with this like an optimistic approach.
 048       */
 049      const LOCK_NONE = 0;
 050   
 051      /**
 052       * Creates an application-level lock on a session. The disadvantage is that the
 053       * lock is not enforced by the database and thus other, unaware parts of the
 054       * application could still concurrently modify the session. The advantage is it
 055       * does not require a transaction.
 056       * This mode is not available for SQLite and not yet implemented for oci and sqlsrv.
 057       */
 058      const LOCK_ADVISORY = 1;
 059   
 060      /**
 061       * Issues a real row lock. Since it uses a transaction between opening and
 062       * closing a session, you have to be careful when you use same database connection
 063       * that you also use for your application logic. This mode is the default because
 064       * it's the only reliable solution across DBMSs.
 065       */
 066      const LOCK_TRANSACTIONAL = 2;
 067   
 068      /**
 069       * @var \PDO|null PDO instance or null when not connected yet
 070       */
 071      private $pdo;
 072   
 073      /**
 074       * @var string|false|null DSN string or null for session.save_path or false when lazy connection disabled
 075       */
 076      private $dsn = false;
 077   
 078      /**
 079       * @var string Database driver
 080       */
 081      private $driver;
 082   
 083      /**
 084       * @var string Table name
 085       */
 086      private $table = 'sessions';
 087   
 088      /**
 089       * @var string Column for session id
 090       */
 091      private $idCol = 'sess_id';
 092   
 093      /**
 094       * @var string Column for session data
 095       */
 096      private $dataCol = 'sess_data';
 097   
 098      /**
 099       * @var string Column for lifetime
 100       */
 101      private $lifetimeCol = 'sess_lifetime';
 102   
 103      /**
 104       * @var string Column for timestamp
 105       */
 106      private $timeCol = 'sess_time';
 107   
 108      /**
 109       * @var string Username when lazy-connect
 110       */
 111      private $username = '';
 112   
 113      /**
 114       * @var string Password when lazy-connect
 115       */
 116      private $password = '';
 117   
 118      /**
 119       * @var array Connection options when lazy-connect
 120       */
 121      private $connectionOptions = [];
 122   
 123      /**
 124       * @var int The strategy for locking, see constants
 125       */
 126      private $lockMode = self::LOCK_TRANSACTIONAL;
 127   
 128      /**
 129       * It's an array to support multiple reads before closing which is manual, non-standard usage.
 130       *
 131       * @var \PDOStatement[] An array of statements to release advisory locks
 132       */
 133      private $unlockStatements = [];
 134   
 135      /**
 136       * @var bool True when the current session exists but expired according to session.gc_maxlifetime
 137       */
 138      private $sessionExpired = false;
 139   
 140      /**
 141       * @var bool Whether a transaction is active
 142       */
 143      private $inTransaction = false;
 144   
 145      /**
 146       * @var bool Whether gc() has been called
 147       */
 148      private $gcCalled = false;
 149   
 150      /**
 151       * You can either pass an existing database connection as PDO instance or
 152       * pass a DSN string that will be used to lazy-connect to the database
 153       * when the session is actually used. Furthermore it's possible to pass null
 154       * which will then use the session.save_path ini setting as PDO DSN parameter.
 155       *
 156       * List of available options:
 157       *  * db_table: The name of the table [default: sessions]
 158       *  * db_id_col: The column where to store the session id [default: sess_id]
 159       *  * db_data_col: The column where to store the session data [default: sess_data]
 160       *  * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime]
 161       *  * db_time_col: The column where to store the timestamp [default: sess_time]
 162       *  * db_username: The username when lazy-connect [default: '']
 163       *  * db_password: The password when lazy-connect [default: '']
 164       *  * db_connection_options: An array of driver-specific connection options [default: []]
 165       *  * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL]
 166       *
 167       * @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or URL string or null
 168       * @param array            $options  An associative array of options
 169       *
 170       * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION
 171       */
 172      public function __construct($pdoOrDsn = null, array $options = [])
 173      {
 174          if ($pdoOrDsn instanceof \PDO) {
 175              if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
 176                  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__));
 177              }
 178   
 179              $this->pdo = $pdoOrDsn;
 180              $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
 181          } elseif (\is_string($pdoOrDsn) && false !== strpos($pdoOrDsn, '://')) {
 182              $this->dsn = $this->buildDsnFromUrl($pdoOrDsn);
 183          } else {
 184              $this->dsn = $pdoOrDsn;
 185          }
 186   
 187          $this->table = isset($options['db_table']) ? $options['db_table'] : $this->table;
 188          $this->idCol = isset($options['db_id_col']) ? $options['db_id_col'] : $this->idCol;
 189          $this->dataCol = isset($options['db_data_col']) ? $options['db_data_col'] : $this->dataCol;
 190          $this->lifetimeCol = isset($options['db_lifetime_col']) ? $options['db_lifetime_col'] : $this->lifetimeCol;
 191          $this->timeCol = isset($options['db_time_col']) ? $options['db_time_col'] : $this->timeCol;
 192          $this->username = isset($options['db_username']) ? $options['db_username'] : $this->username;
 193          $this->password = isset($options['db_password']) ? $options['db_password'] : $this->password;
 194          $this->connectionOptions = isset($options['db_connection_options']) ? $options['db_connection_options'] : $this->connectionOptions;
 195          $this->lockMode = isset($options['lock_mode']) ? $options['lock_mode'] : $this->lockMode;
 196      }
 197   
 198      /**
 199       * Creates the table to store sessions which can be called once for setup.
 200       *
 201       * Session ID is saved in a column of maximum length 128 because that is enough even
 202       * for a 512 bit configured session.hash_function like Whirlpool. Session data is
 203       * saved in a BLOB. One could also use a shorter inlined varbinary column
 204       * if one was sure the data fits into it.
 205       *
 206       * @throws \PDOException    When the table already exists
 207       * @throws \DomainException When an unsupported PDO driver is used
 208       */
 209      public function createTable()
 210      {
 211          // connect if we are not yet
 212          $this->getConnection();
 213   
 214          switch ($this->driver) {
 215              case 'mysql':
 216                  // We use varbinary for the ID column because it prevents unwanted conversions:
 217                  // - character set conversions between server and client
 218                  // - trailing space removal
 219                  // - case-insensitivity
 220                  // - language processing like é == e
 221                  $sql = "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";
 222                  break;
 223              case 'sqlite':
 224                  $sql = "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)";
 225                  break;
 226              case 'pgsql':
 227                  $sql = "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)";
 228                  break;
 229              case 'oci':
 230                  $sql = "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)";
 231                  break;
 232              case 'sqlsrv':
 233                  $sql = "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)";
 234                  break;
 235              default:
 236                  throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver));
 237          }
 238   
 239          try {
 240              $this->pdo->exec($sql);
 241          } catch (\PDOException $e) {
 242              $this->rollback();
 243   
 244              throw $e;
 245          }
 246      }
 247   
 248      /**
 249       * Returns true when the current session exists but expired according to session.gc_maxlifetime.
 250       *
 251       * Can be used to distinguish between a new session and one that expired due to inactivity.
 252       *
 253       * @return bool Whether current session expired
 254       */
 255      public function isSessionExpired()
 256      {
 257          return $this->sessionExpired;
 258      }
 259   
 260      /**
 261       * {@inheritdoc}
 262       */
 263      public function open($savePath, $sessionName)
 264      {
 265          $this->sessionExpired = false;
 266   
 267          if (null === $this->pdo) {
 268              $this->connect($this->dsn ?: $savePath);
 269          }
 270   
 271          return parent::open($savePath, $sessionName);
 272      }
 273   
 274      /**
 275       * {@inheritdoc}
 276       */
 277      public function read($sessionId)
 278      {
 279          try {
 280              return parent::read($sessionId);
 281          } catch (\PDOException $e) {
 282              $this->rollback();
 283   
 284              throw $e;
 285          }
 286      }
 287   
 288      /**
 289       * @return bool
 290       */
 291      public function gc($maxlifetime)
 292      {
 293          // We delay gc() to close() so that it is executed outside the transactional and blocking read-write process.
 294          // This way, pruning expired sessions does not block them from being started while the current session is used.
 295          $this->gcCalled = true;
 296   
 297          return true;
 298      }
 299   
 300      /**
 301       * {@inheritdoc}
 302       */
 303      protected function doDestroy($sessionId)
 304      {
 305          // delete the record associated with this id
 306          $sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
 307   
 308          try {
 309              $stmt = $this->pdo->prepare($sql);
 310              $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
 311              $stmt->execute();
 312          } catch (\PDOException $e) {
 313              $this->rollback();
 314   
 315              throw $e;
 316          }
 317   
 318          return true;
 319      }
 320   
 321      /**
 322       * {@inheritdoc}
 323       */
 324      protected function doWrite($sessionId, $data)
 325      {
 326          $maxlifetime = (int) ini_get('session.gc_maxlifetime');
 327   
 328          try {
 329              // We use a single MERGE SQL query when supported by the database.
 330              $mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime);
 331              if (null !== $mergeStmt) {
 332                  $mergeStmt->execute();
 333   
 334                  return true;
 335              }
 336   
 337              $updateStmt = $this->getUpdateStatement($sessionId, $data, $maxlifetime);
 338              $updateStmt->execute();
 339   
 340              // When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in
 341              // duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior).
 342              // We can just catch such an error and re-execute the update. This is similar to a serializable
 343              // transaction with retry logic on serialization failures but without the overhead and without possible
 344              // false positives due to longer gap locking.
 345              if (!$updateStmt->rowCount()) {
 346                  try {
 347                      $insertStmt = $this->getInsertStatement($sessionId, $data, $maxlifetime);
 348                      $insertStmt->execute();
 349                  } catch (\PDOException $e) {
 350                      // Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys
 351                      if (0 === strpos($e->getCode(), '23')) {
 352                          $updateStmt->execute();
 353                      } else {
 354                          throw $e;
 355                      }
 356                  }
 357              }
 358          } catch (\PDOException $e) {
 359              $this->rollback();
 360   
 361              throw $e;
 362          }
 363   
 364          return true;
 365      }
 366   
 367      /**
 368       * {@inheritdoc}
 369       */
 370      public function updateTimestamp($sessionId, $data)
 371      {
 372          $maxlifetime = (int) ini_get('session.gc_maxlifetime');
 373   
 374          try {
 375              $updateStmt = $this->pdo->prepare(
 376                  "UPDATE $this->table SET $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"
 377              );
 378              $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
 379              $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
 380              $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
 381              $updateStmt->execute();
 382          } catch (\PDOException $e) {
 383              $this->rollback();
 384   
 385              throw $e;
 386          }
 387   
 388          return true;
 389      }
 390   
 391      /**
 392       * {@inheritdoc}
 393       */
 394      public function close()
 395      {
 396          $this->commit();
 397   
 398          while ($unlockStmt = array_shift($this->unlockStatements)) {
 399              $unlockStmt->execute();
 400          }
 401   
 402          if ($this->gcCalled) {
 403              $this->gcCalled = false;
 404   
 405              // delete the session records that have expired
 406              if ('mysql' === $this->driver) {
 407                  $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time";
 408              } else {
 409                  $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol < :time - $this->timeCol";
 410              }
 411   
 412              $stmt = $this->pdo->prepare($sql);
 413              $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
 414              $stmt->execute();
 415          }
 416   
 417          if (false !== $this->dsn) {
 418              $this->pdo = null; // only close lazy-connection
 419          }
 420   
 421          return true;
 422      }
 423   
 424      /**
 425       * Lazy-connects to the database.
 426       *
 427       * @param string $dsn DSN string
 428       */
 429      private function connect($dsn)
 430      {
 431          $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
 432          $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
 433          $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
 434      }
 435   
 436      /**
 437       * Builds a PDO DSN from a URL-like connection string.
 438       *
 439       * @param string $dsnOrUrl
 440       *
 441       * @return string
 442       *
 443       * @todo implement missing support for oci DSN (which look totally different from other PDO ones)
 444       */
 445      private function buildDsnFromUrl($dsnOrUrl)
 446      {
 447          // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid
 448          $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $dsnOrUrl);
 449   
 450          $params = parse_url($url);
 451   
 452          if (false === $params) {
 453              return $dsnOrUrl; // If the URL is not valid, let's assume it might be a DSN already.
 454          }
 455   
 456          $params = array_map('rawurldecode', $params);
 457   
 458          // Override the default username and password. Values passed through options will still win over these in the constructor.
 459          if (isset($params['user'])) {
 460              $this->username = $params['user'];
 461          }
 462   
 463          if (isset($params['pass'])) {
 464              $this->password = $params['pass'];
 465          }
 466   
 467          if (!isset($params['scheme'])) {
 468              throw new \InvalidArgumentException('URLs without scheme are not supported to configure the PdoSessionHandler.');
 469          }
 470   
 471          $driverAliasMap = [
 472              'mssql' => 'sqlsrv',
 473              'mysql2' => 'mysql', // Amazon RDS, for some weird reason
 474              'postgres' => 'pgsql',
 475              'postgresql' => 'pgsql',
 476              'sqlite3' => 'sqlite',
 477          ];
 478   
 479          $driver = isset($driverAliasMap[$params['scheme']]) ? $driverAliasMap[$params['scheme']] : $params['scheme'];
 480   
 481          // Doctrine DBAL supports passing its internal pdo_* driver names directly too (allowing both dashes and underscores). This allows supporting the same here.
 482          if (0 === strpos($driver, 'pdo_') || 0 === strpos($driver, 'pdo-')) {
 483              $driver = substr($driver, 4);
 484          }
 485   
 486          switch ($driver) {
 487              case 'mysql':
 488              case 'pgsql':
 489                  $dsn = $driver.':';
 490   
 491                  if (isset($params['host']) && '' !== $params['host']) {
 492                      $dsn .= 'host='.$params['host'].';';
 493                  }
 494   
 495                  if (isset($params['port']) && '' !== $params['port']) {
 496                      $dsn .= 'port='.$params['port'].';';
 497                  }
 498   
 499                  if (isset($params['path'])) {
 500                      $dbName = substr($params['path'], 1); // Remove the leading slash
 501                      $dsn .= 'dbname='.$dbName.';';
 502                  }
 503   
 504                  return $dsn;
 505   
 506              case 'sqlite':
 507                  return 'sqlite:'.substr($params['path'], 1);
 508   
 509              case 'sqlsrv':
 510                  $dsn = 'sqlsrv:server=';
 511   
 512                  if (isset($params['host'])) {
 513                      $dsn .= $params['host'];
 514                  }
 515   
 516                  if (isset($params['port']) && '' !== $params['port']) {
 517                      $dsn .= ','.$params['port'];
 518                  }
 519   
 520                  if (isset($params['path'])) {
 521                      $dbName = substr($params['path'], 1); // Remove the leading slash
 522                      $dsn .= ';Database='.$dbName;
 523                  }
 524   
 525                  return $dsn;
 526   
 527              default:
 528                  throw new \InvalidArgumentException(sprintf('The scheme "%s" is not supported by the PdoSessionHandler URL configuration. Pass a PDO DSN directly.', $params['scheme']));
 529          }
 530      }
 531   
 532      /**
 533       * Helper method to begin a transaction.
 534       *
 535       * Since SQLite does not support row level locks, we have to acquire a reserved lock
 536       * on the database immediately. Because of https://bugs.php.net/42766 we have to create
 537       * such a transaction manually which also means we cannot use PDO::commit or
 538       * PDO::rollback or PDO::inTransaction for SQLite.
 539       *
 540       * Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions
 541       * due to https://percona.com/blog/2013/12/12/one-more-innodb-gap-lock-to-avoid/ .
 542       * So we change it to READ COMMITTED.
 543       */
 544      private function beginTransaction()
 545      {
 546          if (!$this->inTransaction) {
 547              if ('sqlite' === $this->driver) {
 548                  $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
 549              } else {
 550                  if ('mysql' === $this->driver) {
 551                      $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
 552                  }
 553                  $this->pdo->beginTransaction();
 554              }
 555              $this->inTransaction = true;
 556          }
 557      }
 558   
 559      /**
 560       * Helper method to commit a transaction.
 561       */
 562      private function commit()
 563      {
 564          if ($this->inTransaction) {
 565              try {
 566                  // commit read-write transaction which also releases the lock
 567                  if ('sqlite' === $this->driver) {
 568                      $this->pdo->exec('COMMIT');
 569                  } else {
 570                      $this->pdo->commit();
 571                  }
 572                  $this->inTransaction = false;
 573              } catch (\PDOException $e) {
 574                  $this->rollback();
 575   
 576                  throw $e;
 577              }
 578          }
 579      }
 580   
 581      /**
 582       * Helper method to rollback a transaction.
 583       */
 584      private function rollback()
 585      {
 586          // We only need to rollback if we are in a transaction. Otherwise the resulting
 587          // error would hide the real problem why rollback was called. We might not be
 588          // in a transaction when not using the transactional locking behavior or when
 589          // two callbacks (e.g. destroy and write) are invoked that both fail.
 590          if ($this->inTransaction) {
 591              if ('sqlite' === $this->driver) {
 592                  $this->pdo->exec('ROLLBACK');
 593              } else {
 594                  $this->pdo->rollBack();
 595              }
 596              $this->inTransaction = false;
 597          }
 598      }
 599   
 600      /**
 601       * Reads the session data in respect to the different locking strategies.
 602       *
 603       * We need to make sure we do not return session data that is already considered garbage according
 604       * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes.
 605       *
 606       * @param string $sessionId Session ID
 607       *
 608       * @return string The session data
 609       */
 610      protected function doRead($sessionId)
 611      {
 612          if (self::LOCK_ADVISORY === $this->lockMode) {
 613              $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
 614          }
 615   
 616          $selectSql = $this->getSelectSql();
 617          $selectStmt = $this->pdo->prepare($selectSql);
 618          $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
 619          $insertStmt = null;
 620   
 621          do {
 622              $selectStmt->execute();
 623              $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
 624   
 625              if ($sessionRows) {
 626                  if ($sessionRows[0][1] + $sessionRows[0][2] < time()) {
 627                      $this->sessionExpired = true;
 628   
 629                      return '';
 630                  }
 631   
 632                  return \is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
 633              }
 634   
 635              if (null !== $insertStmt) {
 636                  $this->rollback();
 637                  throw new \RuntimeException('Failed to read session: INSERT reported a duplicate id but next SELECT did not return any data.');
 638              }
 639   
 640              if (!filter_var(ini_get('session.use_strict_mode'), \FILTER_VALIDATE_BOOLEAN) && self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
 641                  // In strict mode, session fixation is not possible: new sessions always start with a unique
 642                  // random id, so that concurrency is not possible and this code path can be skipped.
 643                  // Exclusive-reading of non-existent rows does not block, so we need to do an insert to block
 644                  // until other connections to the session are committed.
 645                  try {
 646                      $insertStmt = $this->getInsertStatement($sessionId, '', 0);
 647                      $insertStmt->execute();
 648                  } catch (\PDOException $e) {
 649                      // Catch duplicate key error because other connection created the session already.
 650                      // It would only not be the case when the other connection destroyed the session.
 651                      if (0 === strpos($e->getCode(), '23')) {
 652                          // Retrieve finished session data written by concurrent connection by restarting the loop.
 653                          // We have to start a new transaction as a failed query will mark the current transaction as
 654                          // aborted in PostgreSQL and disallow further queries within it.
 655                          $this->rollback();
 656                          $this->beginTransaction();
 657                          continue;
 658                      }
 659   
 660                      throw $e;
 661                  }
 662              }
 663   
 664              return '';
 665          } while (true);
 666      }
 667   
 668      /**
 669       * Executes an application-level lock on the database.
 670       *
 671       * @param string $sessionId Session ID
 672       *
 673       * @return \PDOStatement The statement that needs to be executed later to release the lock
 674       *
 675       * @throws \DomainException When an unsupported PDO driver is used
 676       *
 677       * @todo implement missing advisory locks
 678       *       - for oci using DBMS_LOCK.REQUEST
 679       *       - for sqlsrv using sp_getapplock with LockOwner = Session
 680       */
 681      private function doAdvisoryLock($sessionId)
 682      {
 683          switch ($this->driver) {
 684              case 'mysql':
 685                  // MySQL 5.7.5 and later enforces a maximum length on lock names of 64 characters. Previously, no limit was enforced.
 686                  $lockId = substr($sessionId, 0, 64);
 687                  // should we handle the return value? 0 on timeout, null on error
 688                  // we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout
 689                  $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
 690                  $stmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
 691                  $stmt->execute();
 692   
 693                  $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
 694                  $releaseStmt->bindValue(':key', $lockId, \PDO::PARAM_STR);
 695   
 696                  return $releaseStmt;
 697              case 'pgsql':
 698                  // Obtaining an exclusive session level advisory lock requires an integer key.
 699                  // When session.sid_bits_per_character > 4, the session id can contain non-hex-characters.
 700                  // So we cannot just use hexdec().
 701                  if (4 === \PHP_INT_SIZE) {
 702                      $sessionInt1 = $this->convertStringToInt($sessionId);
 703                      $sessionInt2 = $this->convertStringToInt(substr($sessionId, 4, 4));
 704   
 705                      $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
 706                      $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
 707                      $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
 708                      $stmt->execute();
 709   
 710                      $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
 711                      $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
 712                      $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
 713                  } else {
 714                      $sessionBigInt = $this->convertStringToInt($sessionId);
 715   
 716                      $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
 717                      $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
 718                      $stmt->execute();
 719   
 720                      $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
 721                      $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
 722                  }
 723   
 724                  return $releaseStmt;
 725              case 'sqlite':
 726                  throw new \DomainException('SQLite does not support advisory locks.');
 727              default:
 728                  throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
 729          }
 730      }
 731   
 732      /**
 733       * Encodes the first 4 (when PHP_INT_SIZE == 4) or 8 characters of the string as an integer.
 734       *
 735       * Keep in mind, PHP integers are signed.
 736       *
 737       * @param string $string
 738       *
 739       * @return int
 740       */
 741      private function convertStringToInt($string)
 742      {
 743          if (4 === \PHP_INT_SIZE) {
 744              return (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
 745          }
 746   
 747          $int1 = (\ord($string[7]) << 24) + (\ord($string[6]) << 16) + (\ord($string[5]) << 8) + \ord($string[4]);
 748          $int2 = (\ord($string[3]) << 24) + (\ord($string[2]) << 16) + (\ord($string[1]) << 8) + \ord($string[0]);
 749   
 750          return $int2 + ($int1 << 32);
 751      }
 752   
 753      /**
 754       * Return a locking or nonlocking SQL query to read session information.
 755       *
 756       * @return string The SQL string
 757       *
 758       * @throws \DomainException When an unsupported PDO driver is used
 759       */
 760      private function getSelectSql()
 761      {
 762          if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
 763              $this->beginTransaction();
 764   
 765              switch ($this->driver) {
 766                  case 'mysql':
 767                  case 'oci':
 768                  case 'pgsql':
 769                      return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
 770                  case 'sqlsrv':
 771                      return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
 772                  case 'sqlite':
 773                      // we already locked when starting transaction
 774                      break;
 775                  default:
 776                      throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
 777              }
 778          }
 779   
 780          return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id";
 781      }
 782   
 783      /**
 784       * Returns an insert statement supported by the database for writing session data.
 785       *
 786       * @param string $sessionId   Session ID
 787       * @param string $sessionData Encoded session data
 788       * @param int    $maxlifetime session.gc_maxlifetime
 789       *
 790       * @return \PDOStatement The insert statement
 791       */
 792      private function getInsertStatement($sessionId, $sessionData, $maxlifetime)
 793      {
 794          switch ($this->driver) {
 795              case 'oci':
 796                  $data = fopen('php://memory', 'r+');
 797                  fwrite($data, $sessionData);
 798                  rewind($data);
 799                  $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, EMPTY_BLOB(), :lifetime, :time) RETURNING $this->dataCol into :data";
 800                  break;
 801              default:
 802                  $data = $sessionData;
 803                  $sql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)";
 804                  break;
 805          }
 806   
 807          $stmt = $this->pdo->prepare($sql);
 808          $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
 809          $stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
 810          $stmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
 811          $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
 812   
 813          return $stmt;
 814      }
 815   
 816      /**
 817       * Returns an update statement supported by the database for writing session data.
 818       *
 819       * @param string $sessionId   Session ID
 820       * @param string $sessionData Encoded session data
 821       * @param int    $maxlifetime session.gc_maxlifetime
 822       *
 823       * @return \PDOStatement The update statement
 824       */
 825      private function getUpdateStatement($sessionId, $sessionData, $maxlifetime)
 826      {
 827          switch ($this->driver) {
 828              case 'oci':
 829                  $data = fopen('php://memory', 'r+');
 830                  fwrite($data, $sessionData);
 831                  rewind($data);
 832                  $sql = "UPDATE $this->table SET $this->dataCol = EMPTY_BLOB(), $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id RETURNING $this->dataCol into :data";
 833                  break;
 834              default:
 835                  $data = $sessionData;
 836                  $sql = "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id";
 837                  break;
 838          }
 839   
 840          $stmt = $this->pdo->prepare($sql);
 841          $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
 842          $stmt->bindParam(':data', $data, \PDO::PARAM_LOB);
 843          $stmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
 844          $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
 845   
 846          return $stmt;
 847      }
 848   
 849      /**
 850       * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data.
 851       *
 852       * @param string $sessionId   Session ID
 853       * @param string $data        Encoded session data
 854       * @param int    $maxlifetime session.gc_maxlifetime
 855       *
 856       * @return \PDOStatement|null The merge statement or null when not supported
 857       */
 858      private function getMergeStatement($sessionId, $data, $maxlifetime)
 859      {
 860          switch (true) {
 861              case 'mysql' === $this->driver:
 862                  $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
 863                      "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
 864                  break;
 865              case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
 866                  // MERGE is only available since SQL Server 2008 and must be terminated by semicolon
 867                  // It also requires HOLDLOCK according to https://weblogs.sqlteam.com/dang/2009/01/31/upsert-race-condition-with-merge/
 868                  $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
 869                      "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
 870                      "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
 871                  break;
 872              case 'sqlite' === $this->driver:
 873                  $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)";
 874                  break;
 875              case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='):
 876                  $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
 877                      "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
 878                  break;
 879              default:
 880                  // MERGE is not supported with LOBs: https://oracle.com/technetwork/articles/fuecks-lobs-095315.html
 881                  return null;
 882          }
 883   
 884          $mergeStmt = $this->pdo->prepare($mergeSql);
 885   
 886          if ('sqlsrv' === $this->driver) {
 887              $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
 888              $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
 889              $mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB);
 890              $mergeStmt->bindParam(4, $maxlifetime, \PDO::PARAM_INT);
 891              $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
 892              $mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB);
 893              $mergeStmt->bindParam(7, $maxlifetime, \PDO::PARAM_INT);
 894              $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
 895          } else {
 896              $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
 897              $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
 898              $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
 899              $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
 900          }
 901   
 902          return $mergeStmt;
 903      }
 904   
 905      /**
 906       * Return a PDO instance.
 907       *
 908       * @return \PDO
 909       */
 910      protected function getConnection()
 911      {
 912          if (null === $this->pdo) {
 913              $this->connect($this->dsn ?: ini_get('session.save_path'));
 914          }
 915   
 916          return $this->pdo;
 917      }
 918  }
 919