1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/* |
4
|
|
|
* This file is part of the Symfony package. |
5
|
|
|
* |
6
|
|
|
* (c) Fabien Potencier <[email protected]> |
7
|
|
|
* |
8
|
|
|
* For the full copyright and license information, please view the LICENSE |
9
|
|
|
* file that was distributed with this source code. |
10
|
|
|
*/ |
11
|
|
|
|
12
|
|
|
namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; |
13
|
|
|
|
14
|
|
|
/** |
15
|
|
|
* Session handler using a PDO connection to read and write data. |
16
|
|
|
* |
17
|
|
|
* It works with MySQL, PostgreSQL, Oracle, SQL Server and SQLite and implements |
18
|
|
|
* different locking strategies to handle concurrent access to the same session. |
19
|
|
|
* Locking is necessary to prevent loss of data due to race conditions and to keep |
20
|
|
|
* the session data consistent between read() and write(). With locking, requests |
21
|
|
|
* for the same session will wait until the other one finished writing. For this |
22
|
|
|
* reason it's best practice to close a session as early as possible to improve |
23
|
|
|
* concurrency. PHPs internal files session handler also implements locking. |
24
|
|
|
* |
25
|
|
|
* Attention: Since SQLite does not support row level locks but locks the whole database, |
26
|
|
|
* it means only one session can be accessed at a time. Even different sessions would wait |
27
|
|
|
* for another to finish. So saving session in SQLite should only be considered for |
28
|
|
|
* development or prototypes. |
29
|
|
|
* |
30
|
|
|
* Session data is a binary string that can contain non-printable characters like the null byte. |
31
|
|
|
* For this reason it must be saved in a binary column in the database like BLOB in MySQL. |
32
|
|
|
* Saving it in a character column could corrupt the data. You can use createTable() |
33
|
|
|
* to initialize a correctly defined table. |
34
|
|
|
* |
35
|
|
|
* @see http://php.net/sessionhandlerinterface |
36
|
|
|
* |
37
|
|
|
* @author Fabien Potencier <[email protected]> |
38
|
|
|
* @author Michael Williams <[email protected]> |
39
|
|
|
* @author Tobias Schultze <http://tobion.de> |
40
|
|
|
*/ |
41
|
|
|
class PdoSessionHandler implements \SessionHandlerInterface |
42
|
|
|
{ |
43
|
|
|
/** |
44
|
|
|
* No locking is done. This means sessions are prone to loss of data due to |
45
|
|
|
* race conditions of concurrent requests to the same session. The last session |
46
|
|
|
* write will win in this case. It might be useful when you implement your own |
47
|
|
|
* logic to deal with this like an optimistic approach. |
48
|
|
|
*/ |
49
|
|
|
const LOCK_NONE = 0; |
50
|
|
|
|
51
|
|
|
/** |
52
|
|
|
* Creates an application-level lock on a session. The disadvantage is that the |
53
|
|
|
* lock is not enforced by the database and thus other, unaware parts of the |
54
|
|
|
* application could still concurrently modify the session. The advantage is it |
55
|
|
|
* does not require a transaction. |
56
|
|
|
* This mode is not available for SQLite and not yet implemented for oci and sqlsrv. |
57
|
|
|
*/ |
58
|
|
|
const LOCK_ADVISORY = 1; |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* Issues a real row lock. Since it uses a transaction between opening and |
62
|
|
|
* closing a session, you have to be careful when you use same database connection |
63
|
|
|
* that you also use for your application logic. This mode is the default because |
64
|
|
|
* it's the only reliable solution across DBMSs. |
65
|
|
|
*/ |
66
|
|
|
const LOCK_TRANSACTIONAL = 2; |
67
|
|
|
|
68
|
|
|
/** |
69
|
|
|
* @var \PDO|null PDO instance or null when not connected yet |
70
|
|
|
*/ |
71
|
|
|
private $pdo; |
72
|
|
|
|
73
|
|
|
/** |
74
|
|
|
* @var string|null|false DSN string or null for session.save_path or false when lazy connection disabled |
75
|
|
|
*/ |
76
|
|
|
private $dsn = false; |
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* @var string Database driver |
80
|
|
|
*/ |
81
|
|
|
private $driver; |
82
|
|
|
|
83
|
|
|
/** |
84
|
|
|
* @var string Table name |
85
|
|
|
*/ |
86
|
|
|
private $table = 'sessions'; |
87
|
|
|
|
88
|
|
|
/** |
89
|
|
|
* @var string Column for session id |
90
|
|
|
*/ |
91
|
|
|
private $idCol = 'sess_id'; |
92
|
|
|
|
93
|
|
|
/** |
94
|
|
|
* @var string Column for session data |
95
|
|
|
*/ |
96
|
|
|
private $dataCol = 'sess_data'; |
97
|
|
|
|
98
|
|
|
/** |
99
|
|
|
* @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 = array(); |
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 = array(); |
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
|
|
|
* Constructor. |
152
|
|
|
* |
153
|
|
|
* You can either pass an existing database connection as PDO instance or |
154
|
|
|
* pass a DSN string that will be used to lazy-connect to the database |
155
|
|
|
* when the session is actually used. Furthermore it's possible to pass null |
156
|
|
|
* which will then use the session.save_path ini setting as PDO DSN parameter. |
157
|
|
|
* |
158
|
|
|
* List of available options: |
159
|
|
|
* * db_table: The name of the table [default: sessions] |
160
|
|
|
* * db_id_col: The column where to store the session id [default: sess_id] |
161
|
|
|
* * db_data_col: The column where to store the session data [default: sess_data] |
162
|
|
|
* * db_lifetime_col: The column where to store the lifetime [default: sess_lifetime] |
163
|
|
|
* * db_time_col: The column where to store the timestamp [default: sess_time] |
164
|
|
|
* * db_username: The username when lazy-connect [default: ''] |
165
|
|
|
* * db_password: The password when lazy-connect [default: ''] |
166
|
|
|
* * db_connection_options: An array of driver-specific connection options [default: array()] |
167
|
|
|
* * lock_mode: The strategy for locking, see constants [default: LOCK_TRANSACTIONAL] |
168
|
|
|
* |
169
|
|
|
* @param \PDO|string|null $pdoOrDsn A \PDO instance or DSN string or null |
170
|
|
|
* @param array $options An associative array of options |
171
|
|
|
* |
172
|
|
|
* @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION |
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
|
|
|
* 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
|
|
View Code Duplication |
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 MEDIUMINT NOT NULL, $this->timeCol INTEGER UNSIGNED NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB"; |
222
|
|
|
break; |
223
|
|
View Code Duplication |
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
|
|
View Code Duplication |
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
|
|
View Code Duplication |
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
|
|
View Code Duplication |
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
|
|
|
if (null === $this->pdo) { |
266
|
|
|
$this->connect($this->dsn ?: $savePath); |
267
|
|
|
} |
268
|
|
|
|
269
|
|
|
return true; |
270
|
|
|
} |
271
|
|
|
|
272
|
|
|
/** |
273
|
|
|
* {@inheritdoc} |
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
|
|
|
* {@inheritdoc} |
288
|
|
|
*/ |
289
|
|
|
public function gc($maxlifetime) |
290
|
|
|
{ |
291
|
|
|
// We delay gc() to close() so that it is executed outside the transactional and blocking read-write process. |
292
|
|
|
// This way, pruning expired sessions does not block them from being started while the current session is used. |
293
|
|
|
$this->gcCalled = true; |
294
|
|
|
|
295
|
|
|
return true; |
296
|
|
|
} |
297
|
|
|
|
298
|
|
|
/** |
299
|
|
|
* {@inheritdoc} |
300
|
|
|
*/ |
301
|
|
|
public function destroy($sessionId) |
302
|
|
|
{ |
303
|
|
|
// delete the record associated with this id |
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
|
|
|
* {@inheritdoc} |
321
|
|
|
*/ |
322
|
|
|
public function write($sessionId, $data) |
323
|
|
|
{ |
324
|
|
|
$maxlifetime = (int) ini_get('session.gc_maxlifetime'); |
325
|
|
|
|
326
|
|
|
try { |
327
|
|
|
// We use a single MERGE SQL query when supported by the database. |
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
|
|
|
// When MERGE is not supported, like in Postgres < 9.5, we have to use this approach that can result in |
345
|
|
|
// duplicate key errors when the same session is written simultaneously (given the LOCK_NONE behavior). |
346
|
|
|
// We can just catch such an error and re-execute the update. This is similar to a serializable |
347
|
|
|
// transaction with retry logic on serialization failures but without the overhead and without possible |
348
|
|
|
// false positives due to longer gap locking. |
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
|
|
|
// Handle integrity violation SQLSTATE 23000 (or a subclass like 23505 in Postgres) for duplicate keys |
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
|
|
|
* {@inheritdoc} |
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
|
|
|
// delete the session records that have expired |
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; // only close lazy-connection |
401
|
|
|
} |
402
|
|
|
|
403
|
|
|
return true; |
404
|
|
|
} |
405
|
|
|
|
406
|
|
|
/** |
407
|
|
|
* Lazy-connects to the database. |
408
|
|
|
* |
409
|
|
|
* @param string $dsn DSN string |
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
|
|
|
* Helper method to begin a transaction. |
420
|
|
|
* |
421
|
|
|
* Since SQLite does not support row level locks, we have to acquire a reserved lock |
422
|
|
|
* on the database immediately. Because of https://bugs.php.net/42766 we have to create |
423
|
|
|
* such a transaction manually which also means we cannot use PDO::commit or |
424
|
|
|
* PDO::rollback or PDO::inTransaction for SQLite. |
425
|
|
|
* |
426
|
|
|
* Also MySQLs default isolation, REPEATABLE READ, causes deadlock for different sessions |
427
|
|
|
* due to http://www.mysqlperformanceblog.com/2013/12/12/one-more-innodb-gap-lock-to-avoid/ . |
428
|
|
|
* So we change it to READ COMMITTED. |
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
|
|
|
* Helper method to commit a transaction. |
447
|
|
|
*/ |
448
|
|
View Code Duplication |
private function commit() |
|
|
|
|
449
|
|
|
{ |
450
|
|
|
if ($this->inTransaction) { |
451
|
|
|
try { |
452
|
|
|
// commit read-write transaction which also releases the lock |
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
|
|
|
* Helper method to rollback a transaction. |
469
|
|
|
*/ |
470
|
|
View Code Duplication |
private function rollback() |
|
|
|
|
471
|
|
|
{ |
472
|
|
|
// We only need to rollback if we are in a transaction. Otherwise the resulting |
473
|
|
|
// error would hide the real problem why rollback was called. We might not be |
474
|
|
|
// in a transaction when not using the transactional locking behavior or when |
475
|
|
|
// two callbacks (e.g. destroy and write) are invoked that both fail. |
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
|
|
|
* Reads the session data in respect to the different locking strategies. |
488
|
|
|
* |
489
|
|
|
* We need to make sure we do not return session data that is already considered garbage according |
490
|
|
|
* to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes. |
491
|
|
|
* |
492
|
|
|
* @param string $sessionId Session ID |
493
|
|
|
* |
494
|
|
|
* @return string The session data |
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
|
|
|
// Exclusive-reading of non-existent rows does not block, so we need to do an insert to block |
524
|
|
|
// until other connections to the session are committed. |
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
|
|
|
// Catch duplicate key error because other connection created the session already. |
536
|
|
|
// It would only not be the case when the other connection destroyed the session. |
537
|
|
|
if (0 === strpos($e->getCode(), '23')) { |
538
|
|
|
// Retrieve finished session data written by concurrent connection by restarting the loop. |
539
|
|
|
// We have to start a new transaction as a failed query will mark the current transaction as |
540
|
|
|
// aborted in PostgreSQL and disallow further queries within it. |
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
|
|
|
* Executes an application-level lock on the database. |
556
|
|
|
* |
557
|
|
|
* @param string $sessionId Session ID |
558
|
|
|
* |
559
|
|
|
* @return \PDOStatement The statement that needs to be executed later to release the lock |
560
|
|
|
* |
561
|
|
|
* @throws \DomainException When an unsupported PDO driver is used |
562
|
|
|
* |
563
|
|
|
* @todo implement missing advisory locks |
564
|
|
|
* - for oci using DBMS_LOCK.REQUEST |
565
|
|
|
* - for sqlsrv using sp_getapplock with LockOwner = Session |
566
|
|
|
*/ |
567
|
|
|
private function doAdvisoryLock($sessionId) |
568
|
|
|
{ |
569
|
|
|
switch ($this->driver) { |
570
|
|
|
case 'mysql': |
571
|
|
|
// should we handle the return value? 0 on timeout, null on error |
572
|
|
|
// we use a timeout of 50 seconds which is also the default for innodb_lock_wait_timeout |
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
|
|
|
// Obtaining an exclusive session level advisory lock requires an integer key. |
583
|
|
|
// So we convert the HEX representation of the session id to an integer. |
584
|
|
|
// Since integers are signed, we have to skip one hex char to fit in the range. |
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
|
|
|
* Return a locking or nonlocking SQL query to read session information. |
618
|
|
|
* |
619
|
|
|
* @return string The SQL string |
620
|
|
|
* |
621
|
|
|
* @throws \DomainException When an unsupported PDO driver is used |
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
|
|
|
// we already locked when starting transaction |
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
|
|
|
* Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data. |
648
|
|
|
* |
649
|
|
|
* @param string $sessionId Session ID |
650
|
|
|
* @param string $data Encoded session data |
651
|
|
|
* @param int $maxlifetime session.gc_maxlifetime |
652
|
|
|
* |
653
|
|
|
* @return \PDOStatement|null The merge statement or null when not supported |
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
|
|
|
// DUAL is Oracle specific dummy table |
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
|
|
|
// MERGE is only available since SQL Server 2008 and must be terminated by semicolon |
671
|
|
|
// It also requires HOLDLOCK according to http://weblogs.sqlteam.com/dang/archive/2009/01/31/UPSERT-Race-Condition-With-MERGE.aspx |
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
|
|
View Code Duplication |
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
|
|
|
* Return a PDO instance. |
710
|
|
|
* |
711
|
|
|
* @return \PDO |
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
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.