1: <?php
2:
3: 4: 5: 6: 7: 8: 9: 10:
11:
12: namespace Symfony\Component\HttpFoundation\Session\Storage\Handler;
13:
14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40:
41: class PdoSessionHandler implements \SessionHandlerInterface
42: {
43: 44: 45: 46: 47: 48:
49: const LOCK_NONE = 0;
50:
51: 52: 53: 54: 55: 56: 57:
58: const LOCK_ADVISORY = 1;
59:
60: 61: 62: 63: 64: 65:
66: const LOCK_TRANSACTIONAL = 2;
67:
68: 69: 70:
71: private $pdo;
72:
73: 74: 75:
76: private $dsn = false;
77:
78: 79: 80:
81: private $driver;
82:
83: 84: 85:
86: private $table = 'sessions';
87:
88: 89: 90:
91: private $idCol = 'sess_id';
92:
93: 94: 95:
96: private $dataCol = 'sess_data';
97:
98: 99: 100:
101: private $lifetimeCol = 'sess_lifetime';
102:
103: 104: 105:
106: private $timeCol = 'sess_time';
107:
108: 109: 110:
111: private $username = '';
112:
113: 114: 115:
116: private $password = '';
117:
118: 119: 120:
121: private $connectionOptions = array();
122:
123: 124: 125:
126: private $lockMode = self::LOCK_TRANSACTIONAL;
127:
128: 129: 130: 131: 132:
133: private $unlockStatements = array();
134:
135: 136: 137:
138: private $sessionExpired = false;
139:
140: 141: 142:
143: private $inTransaction = false;
144:
145: 146: 147:
148: private $gcCalled = false;
149:
150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173:
174: public function __construct($pdoOrDsn = null, array $options = array())
175: {
176: if ($pdoOrDsn instanceof \PDO) {
177: if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) {
178: 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__));
179: }
180:
181: $this->pdo = $pdoOrDsn;
182: $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
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: 200: 201: 202: 203: 204: 205: 206: 207: 208:
209: public function createTable()
210: {
211:
212: $this->getConnection();
213:
214: switch ($this->driver) {
215: case 'mysql':
216:
217:
218:
219:
220:
221: $sql = "CREATE TABLE $this->table ($this->idCol VARBINARY(128) NOT NULL PRIMARY KEY, $this->dataCol BLOB NOT NULL, $this->lifetimeCol MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_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: 250: 251: 252: 253: 254:
255: public function isSessionExpired()
256: {
257: return $this->sessionExpired;
258: }
259:
260: 261: 262:
263: public function open($savePath, $sessionName)
264: {
265: if (null === $this->pdo) {
266: $this->connect($this->dsn ?: $savePath);
267: }
268:
269: return true;
270: }
271:
272: 273: 274:
275: public function read($sessionId)
276: {
277: try {
278: return $this->doRead($sessionId);
279: } catch (\PDOException $e) {
280: $this->rollback();
281:
282: throw $e;
283: }
284: }
285:
286: 287: 288:
289: public function gc($maxlifetime)
290: {
291:
292:
293: $this->gcCalled = true;
294:
295: return true;
296: }
297:
298: 299: 300:
301: public function destroy($sessionId)
302: {
303:
304: $sql = "DELETE FROM $this->table WHERE $this->idCol = :id";
305:
306: try {
307: $stmt = $this->pdo->prepare($sql);
308: $stmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
309: $stmt->execute();
310: } catch (\PDOException $e) {
311: $this->rollback();
312:
313: throw $e;
314: }
315:
316: return true;
317: }
318:
319: 320: 321:
322: public function write($sessionId, $data)
323: {
324: $maxlifetime = (int) ini_get('session.gc_maxlifetime');
325:
326: try {
327:
328: $mergeStmt = $this->getMergeStatement($sessionId, $data, $maxlifetime);
329: if (null !== $mergeStmt) {
330: $mergeStmt->execute();
331:
332: return true;
333: }
334:
335: $updateStmt = $this->pdo->prepare(
336: "UPDATE $this->table SET $this->dataCol = :data, $this->lifetimeCol = :lifetime, $this->timeCol = :time WHERE $this->idCol = :id"
337: );
338: $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
339: $updateStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
340: $updateStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
341: $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT);
342: $updateStmt->execute();
343:
344:
345:
346:
347:
348:
349: if (!$updateStmt->rowCount()) {
350: try {
351: $insertStmt = $this->pdo->prepare(
352: "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
353: );
354: $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
355: $insertStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
356: $insertStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
357: $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
358: $insertStmt->execute();
359: } catch (\PDOException $e) {
360:
361: if (0 === strpos($e->getCode(), '23')) {
362: $updateStmt->execute();
363: } else {
364: throw $e;
365: }
366: }
367: }
368: } catch (\PDOException $e) {
369: $this->rollback();
370:
371: throw $e;
372: }
373:
374: return true;
375: }
376:
377: 378: 379:
380: public function close()
381: {
382: $this->commit();
383:
384: while ($unlockStmt = array_shift($this->unlockStatements)) {
385: $unlockStmt->execute();
386: }
387:
388: if ($this->gcCalled) {
389: $this->gcCalled = false;
390:
391:
392: $sql = "DELETE FROM $this->table WHERE $this->lifetimeCol + $this->timeCol < :time";
393:
394: $stmt = $this->pdo->prepare($sql);
395: $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
396: $stmt->execute();
397: }
398:
399: if (false !== $this->dsn) {
400: $this->pdo = null;
401: }
402:
403: return true;
404: }
405:
406: 407: 408: 409: 410:
411: private function connect($dsn)
412: {
413: $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions);
414: $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
415: $this->driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
416: }
417:
418: 419: 420: 421: 422: 423: 424: 425: 426: 427: 428: 429:
430: private function beginTransaction()
431: {
432: if (!$this->inTransaction) {
433: if ('sqlite' === $this->driver) {
434: $this->pdo->exec('BEGIN IMMEDIATE TRANSACTION');
435: } else {
436: if ('mysql' === $this->driver) {
437: $this->pdo->exec('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
438: }
439: $this->pdo->beginTransaction();
440: }
441: $this->inTransaction = true;
442: }
443: }
444:
445: 446: 447:
448: private function commit()
449: {
450: if ($this->inTransaction) {
451: try {
452:
453: if ('sqlite' === $this->driver) {
454: $this->pdo->exec('COMMIT');
455: } else {
456: $this->pdo->commit();
457: }
458: $this->inTransaction = false;
459: } catch (\PDOException $e) {
460: $this->rollback();
461:
462: throw $e;
463: }
464: }
465: }
466:
467: 468: 469:
470: private function rollback()
471: {
472:
473:
474:
475:
476: if ($this->inTransaction) {
477: if ('sqlite' === $this->driver) {
478: $this->pdo->exec('ROLLBACK');
479: } else {
480: $this->pdo->rollBack();
481: }
482: $this->inTransaction = false;
483: }
484: }
485:
486: 487: 488: 489: 490: 491: 492: 493: 494: 495:
496: private function doRead($sessionId)
497: {
498: $this->sessionExpired = false;
499:
500: if (self::LOCK_ADVISORY === $this->lockMode) {
501: $this->unlockStatements[] = $this->doAdvisoryLock($sessionId);
502: }
503:
504: $selectSql = $this->getSelectSql();
505: $selectStmt = $this->pdo->prepare($selectSql);
506: $selectStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
507:
508: do {
509: $selectStmt->execute();
510: $sessionRows = $selectStmt->fetchAll(\PDO::FETCH_NUM);
511:
512: if ($sessionRows) {
513: if ($sessionRows[0][1] + $sessionRows[0][2] < time()) {
514: $this->sessionExpired = true;
515:
516: return '';
517: }
518:
519: return is_resource($sessionRows[0][0]) ? stream_get_contents($sessionRows[0][0]) : $sessionRows[0][0];
520: }
521:
522: if (self::LOCK_TRANSACTIONAL === $this->lockMode && 'sqlite' !== $this->driver) {
523:
524:
525: try {
526: $insertStmt = $this->pdo->prepare(
527: "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)"
528: );
529: $insertStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
530: $insertStmt->bindValue(':data', '', \PDO::PARAM_LOB);
531: $insertStmt->bindValue(':lifetime', 0, \PDO::PARAM_INT);
532: $insertStmt->bindValue(':time', time(), \PDO::PARAM_INT);
533: $insertStmt->execute();
534: } catch (\PDOException $e) {
535:
536:
537: if (0 === strpos($e->getCode(), '23')) {
538:
539:
540:
541: $this->rollback();
542: $this->beginTransaction();
543: continue;
544: }
545:
546: throw $e;
547: }
548: }
549:
550: return '';
551: } while (true);
552: }
553:
554: 555: 556: 557: 558: 559: 560: 561: 562: 563: 564: 565: 566:
567: private function doAdvisoryLock($sessionId)
568: {
569: switch ($this->driver) {
570: case 'mysql':
571:
572:
573: $stmt = $this->pdo->prepare('SELECT GET_LOCK(:key, 50)');
574: $stmt->bindValue(':key', $sessionId, \PDO::PARAM_STR);
575: $stmt->execute();
576:
577: $releaseStmt = $this->pdo->prepare('DO RELEASE_LOCK(:key)');
578: $releaseStmt->bindValue(':key', $sessionId, \PDO::PARAM_STR);
579:
580: return $releaseStmt;
581: case 'pgsql':
582:
583:
584:
585: if (4 === PHP_INT_SIZE) {
586: $sessionInt1 = hexdec(substr($sessionId, 0, 7));
587: $sessionInt2 = hexdec(substr($sessionId, 7, 7));
588:
589: $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key1, :key2)');
590: $stmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
591: $stmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
592: $stmt->execute();
593:
594: $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key1, :key2)');
595: $releaseStmt->bindValue(':key1', $sessionInt1, \PDO::PARAM_INT);
596: $releaseStmt->bindValue(':key2', $sessionInt2, \PDO::PARAM_INT);
597: } else {
598: $sessionBigInt = hexdec(substr($sessionId, 0, 15));
599:
600: $stmt = $this->pdo->prepare('SELECT pg_advisory_lock(:key)');
601: $stmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
602: $stmt->execute();
603:
604: $releaseStmt = $this->pdo->prepare('SELECT pg_advisory_unlock(:key)');
605: $releaseStmt->bindValue(':key', $sessionBigInt, \PDO::PARAM_INT);
606: }
607:
608: return $releaseStmt;
609: case 'sqlite':
610: throw new \DomainException('SQLite does not support advisory locks.');
611: default:
612: throw new \DomainException(sprintf('Advisory locks are currently not implemented for PDO driver "%s".', $this->driver));
613: }
614: }
615:
616: 617: 618: 619: 620: 621: 622:
623: private function getSelectSql()
624: {
625: if (self::LOCK_TRANSACTIONAL === $this->lockMode) {
626: $this->beginTransaction();
627:
628: switch ($this->driver) {
629: case 'mysql':
630: case 'oci':
631: case 'pgsql':
632: return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id FOR UPDATE";
633: case 'sqlsrv':
634: return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WITH (UPDLOCK, ROWLOCK) WHERE $this->idCol = :id";
635: case 'sqlite':
636:
637: break;
638: default:
639: throw new \DomainException(sprintf('Transactional locks are currently not implemented for PDO driver "%s".', $this->driver));
640: }
641: }
642:
643: return "SELECT $this->dataCol, $this->lifetimeCol, $this->timeCol FROM $this->table WHERE $this->idCol = :id";
644: }
645:
646: 647: 648: 649: 650: 651: 652: 653: 654:
655: private function getMergeStatement($sessionId, $data, $maxlifetime)
656: {
657: $mergeSql = null;
658: switch (true) {
659: case 'mysql' === $this->driver:
660: $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
661: "ON DUPLICATE KEY UPDATE $this->dataCol = VALUES($this->dataCol), $this->lifetimeCol = VALUES($this->lifetimeCol), $this->timeCol = VALUES($this->timeCol)";
662: break;
663: case 'oci' === $this->driver:
664:
665: $mergeSql = "MERGE INTO $this->table USING DUAL ON ($this->idCol = ?) ".
666: "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
667: "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?";
668: break;
669: case 'sqlsrv' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '10', '>='):
670:
671:
672: $mergeSql = "MERGE INTO $this->table WITH (HOLDLOCK) USING (SELECT 1 AS dummy) AS src ON ($this->idCol = ?) ".
673: "WHEN NOT MATCHED THEN INSERT ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (?, ?, ?, ?) ".
674: "WHEN MATCHED THEN UPDATE SET $this->dataCol = ?, $this->lifetimeCol = ?, $this->timeCol = ?;";
675: break;
676: case 'sqlite' === $this->driver:
677: $mergeSql = "INSERT OR REPLACE INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time)";
678: break;
679: case 'pgsql' === $this->driver && version_compare($this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '9.5', '>='):
680: $mergeSql = "INSERT INTO $this->table ($this->idCol, $this->dataCol, $this->lifetimeCol, $this->timeCol) VALUES (:id, :data, :lifetime, :time) ".
681: "ON CONFLICT ($this->idCol) DO UPDATE SET ($this->dataCol, $this->lifetimeCol, $this->timeCol) = (EXCLUDED.$this->dataCol, EXCLUDED.$this->lifetimeCol, EXCLUDED.$this->timeCol)";
682: break;
683: }
684:
685: if (null !== $mergeSql) {
686: $mergeStmt = $this->pdo->prepare($mergeSql);
687:
688: if ('sqlsrv' === $this->driver || 'oci' === $this->driver) {
689: $mergeStmt->bindParam(1, $sessionId, \PDO::PARAM_STR);
690: $mergeStmt->bindParam(2, $sessionId, \PDO::PARAM_STR);
691: $mergeStmt->bindParam(3, $data, \PDO::PARAM_LOB);
692: $mergeStmt->bindParam(4, $maxlifetime, \PDO::PARAM_INT);
693: $mergeStmt->bindValue(5, time(), \PDO::PARAM_INT);
694: $mergeStmt->bindParam(6, $data, \PDO::PARAM_LOB);
695: $mergeStmt->bindParam(7, $maxlifetime, \PDO::PARAM_INT);
696: $mergeStmt->bindValue(8, time(), \PDO::PARAM_INT);
697: } else {
698: $mergeStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR);
699: $mergeStmt->bindParam(':data', $data, \PDO::PARAM_LOB);
700: $mergeStmt->bindParam(':lifetime', $maxlifetime, \PDO::PARAM_INT);
701: $mergeStmt->bindValue(':time', time(), \PDO::PARAM_INT);
702: }
703:
704: return $mergeStmt;
705: }
706: }
707:
708: 709: 710: 711: 712:
713: protected function getConnection()
714: {
715: if (null === $this->pdo) {
716: $this->connect($this->dsn ?: ini_get('session.save_path'));
717: }
718:
719: return $this->pdo;
720: }
721: }
722: