Skip to content

Commit 14cf7dc

Browse files
committed
NEW Add database session handler implementation
This is an easy option for multi-server hosting environments, though is likely less performant than the other session handlers on offer.
1 parent e7dd735 commit 14cf7dc

File tree

3 files changed

+296
-0
lines changed

3 files changed

+296
-0
lines changed

_config/session.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,16 @@ SilverStripe\Core\Injector\Injector:
2424
# is technically valid so we have to disable containers to avoid
2525
# weird behaviour with versioned, etc.
2626
disable-container: true
27+
28+
SilverStripe\Dev\Command\DbBuild:
29+
extensions:
30+
DbBuildSessionExtension: 'SilverStripe\Control\SessionHandler\DbBuildSessionExtension'
31+
32+
---
33+
Name: 'session-handlers-test-session'
34+
Only:
35+
moduleexists: 'silverstripe/testsession'
36+
---
37+
SilverStripe\TestSession\TestSessionEnvironment:
38+
extensions:
39+
DbBuildSessionExtension: 'SilverStripe\Control\SessionHandler\DbBuildSessionExtension'
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
<?php
2+
3+
namespace SilverStripe\Control\SessionHandler;
4+
5+
use SensitiveParameter;
6+
use SilverStripe\Core\Config\Configurable;
7+
use SilverStripe\Core\Convert;
8+
use SilverStripe\ORM\Connect\Query;
9+
use SilverStripe\ORM\DataObject;
10+
use SilverStripe\ORM\DB;
11+
use SilverStripe\ORM\FieldType\DBDatetime;
12+
use SilverStripe\ORM\Queries\SQLDelete;
13+
use SilverStripe\ORM\Queries\SQLInsert;
14+
use SilverStripe\ORM\Queries\SQLSelect;
15+
use SilverStripe\ORM\Queries\SQLUpdate;
16+
17+
/**
18+
* Session save handler that stores session data in the database.
19+
*/
20+
class DatabaseSessionHandler extends AbstractSessionHandler
21+
{
22+
use Configurable;
23+
24+
private static string $table_name = '_sessions';
25+
26+
public function open(string $path, string $name): bool
27+
{
28+
// No action is required to open the session.
29+
return true;
30+
}
31+
32+
public function close(): bool
33+
{
34+
// No action is required to close the session.
35+
return true;
36+
}
37+
38+
/**
39+
* @inheritDoc
40+
* Clears the cache entry that represents this session ID.
41+
*/
42+
public function destroy(#[SensitiveParameter] string $id): bool
43+
{
44+
if (!$this->isDatabaseReady()) {
45+
return false;
46+
}
47+
48+
SQLDelete::create(
49+
Convert::symbol2sql(static::config()->get('table_name')),
50+
[Convert::symbol2sql('SessionID') => $id]
51+
)->execute();
52+
return true;
53+
}
54+
55+
/**
56+
* @inheritDoc
57+
* Clears all session cache which have a last modified datetime older than the session max lifetime.
58+
* Note that we use our own calculated session lifetime rather than the passed in lifetime which doesn't
59+
* take Silverstripe CMS configuration values into account.
60+
*/
61+
public function gc(int $max_lifetime): int|false
62+
{
63+
if (!$this->isDatabaseReady()) {
64+
return false;
65+
}
66+
67+
SQLDelete::create(
68+
Convert::symbol2sql(static::config()->get('table_name')),
69+
[Convert::symbol2sql('Expiry') . ' < ?' => DBDatetime::now()->getTimestamp()]
70+
)->execute();
71+
return DB::affected_rows();
72+
}
73+
74+
/**
75+
* @inheritDoc
76+
* Returns data of a pre-existing session, or an empty string for a new session.
77+
*/
78+
public function read(#[SensitiveParameter] string $id): string|false
79+
{
80+
if (!$this->isDatabaseReady()) {
81+
return false;
82+
}
83+
84+
/** @var Query $rows */
85+
$rows = DB::withPrimary(fn() => $this->getSqlSelect($id)->execute());
86+
if ($rows->numRecords() === 0) {
87+
return '';
88+
}
89+
return $rows->record()['Data'];
90+
}
91+
92+
/**
93+
* @inheritDoc
94+
* Writes session data to a cache entry.
95+
*/
96+
public function write(#[SensitiveParameter] string $id, string $data): bool
97+
{
98+
if (!$this->isDatabaseReady()) {
99+
return false;
100+
}
101+
102+
if ($this->sessionExists($id, true)) {
103+
$query = SQLUpdate::create(where: [Convert::symbol2sql('SessionID') => $id]);
104+
} else {
105+
$query = SQLInsert::create(assignments: [Convert::symbol2sql('SessionID') => $id]);
106+
}
107+
$query->addFrom(Convert::symbol2sql(static::config()->get('table_name')));
108+
$query->addAssignments([
109+
Convert::symbol2sql('Data') => $data,
110+
Convert::symbol2sql('Expiry') => DBDatetime::now()->getTimestamp() + $this->getLifetime(),
111+
]);
112+
$query->execute();
113+
return true;
114+
}
115+
116+
/**
117+
* @inheritDoc
118+
* A session ID is valid if an entry for that session ID already exists and has not expired.
119+
*/
120+
public function validateId(#[SensitiveParameter] string $id): bool
121+
{
122+
if (!$this->isDatabaseReady()) {
123+
return false;
124+
}
125+
return $this->sessionExists($id);
126+
}
127+
128+
/**
129+
* @inheritDoc
130+
* Called instead of write if session.lazy_write is enabled and no data has changed for this session.
131+
*/
132+
public function updateTimestamp(#[SensitiveParameter] string $id, string $data): bool
133+
{
134+
// The logic for updating the timestamp ends up being effectively identical to just
135+
// writing the session - there's no optimisation to be made by using separate logic.
136+
return $this->write($id, $data);
137+
}
138+
139+
/**
140+
* Add the database table. This is called by an extension when building the db.
141+
* Note that we don't just use a DataObject because:
142+
* 1. We don't want things like versioning, fluent, etc to ever be able to affect sessions
143+
* 2. We don't want developers to be affecting db operations via hooks (interact with sessions with the Session class)
144+
* 3. We don't want sessions to be used in any other ways that DataObjects are often
145+
* 4. We only want to build the table if this is the configured save handler
146+
*/
147+
public function requireTable()
148+
{
149+
$fields = [
150+
'ID' => 'PrimaryKey',
151+
'SessionID' => 'Varchar(64)',
152+
'Expiry' => 'Int',
153+
'Data' => 'Text',
154+
];
155+
$indexes = [ // @TODO - check this is correct syntax
156+
'SessionID' => [
157+
'type' => 'unique' // @TODO can I make this the primary key??
158+
],
159+
'Expiry' => true,
160+
];
161+
DB::get_schema()->schemaUpdate(function () use ($fields, $indexes) {
162+
DB::require_table(
163+
static::config()->get('table_name'),
164+
$fields,
165+
$indexes,
166+
true,
167+
DataObject::config()->get('create_table_options')
168+
);
169+
});
170+
}
171+
172+
/**
173+
* Get an SQLSelect for selecting the data for the given session ID.
174+
* If $allowExpired is false, expired sessions are explicitly excluded.
175+
*/
176+
private function getSqlSelect(#[SensitiveParameter] string $id, bool $allowExpired = false): SQLSelect
177+
{
178+
$select = SQLSelect::create(
179+
Convert::symbol2sql('Data'),
180+
Convert::symbol2sql(static::config()->get('table_name')),
181+
[Convert::symbol2sql('SessionID') => $id],
182+
);
183+
if (!$allowExpired) {
184+
$select->addWhere([Convert::symbol2sql('Expiry') . ' >= ?' => DBDatetime::now()->getTimestamp()]);
185+
}
186+
return $select;
187+
}
188+
189+
/**
190+
* Check if a session with this ID exists.
191+
* If $allowExpired is false, returns false for expired sessions.
192+
*/
193+
private function sessionExists(#[SensitiveParameter] string $id, bool $allowExpired = false): bool
194+
{
195+
// Note this is the same logic used in DataQuery::exists()
196+
$row = DB::withPrimary(function () use ($id, $allowExpired) {
197+
$select = $this->getSqlSelect($id, $allowExpired);
198+
$subQuerySql = $select->sql($params);
199+
$selectExists = SQLSelect::create('1')->addWhere(['EXISTS (' . $subQuerySql . ')' => $params])->execute();
200+
return $selectExists->record();
201+
});
202+
if ($row) {
203+
$result = reset($row);
204+
} else {
205+
$result = false;
206+
}
207+
return $result === true || $result === 1 || $result === '1';
208+
}
209+
210+
private function isDatabaseReady()
211+
{
212+
if (!DB::connection_attempted() || !DB::is_active()) {
213+
return false;
214+
}
215+
return DB::get_schema()->hasTable(static::config()->get('table_name'));
216+
}
217+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
namespace SilverStripe\Control\SessionHandler;
4+
5+
use SilverStripe\Control\Director;
6+
use SilverStripe\Control\Session;
7+
use SilverStripe\Core\Extension;
8+
use SilverStripe\Core\Injector\Injector;
9+
use SilverStripe\PolyExecution\PolyOutput;
10+
11+
/**
12+
* Builds the table for DatabaseSessionHandler if that is the configured session save handler.
13+
*/
14+
class DbBuildSessionExtension extends Extension
15+
{
16+
/**
17+
* This extension hook is on TestSessionEnvironment, which is used by behat but not by phpunit.
18+
* For whatever reason, behat doesn't build the db the normal way, so we can't rely on the below
19+
* onAfterbuild being run in that scenario.
20+
*/
21+
protected function onAfterStartTestSession()
22+
{
23+
$sessionHandler = $this->getSessionHandler();
24+
if (!$sessionHandler) {
25+
return;
26+
}
27+
28+
$output = PolyOutput::create(
29+
Director::is_cli() ? PolyOutput::FORMAT_ANSI : PolyOutput::FORMAT_HTML,
30+
PolyOutput::VERBOSITY_QUIET
31+
);
32+
$output->startList();
33+
$sessionHandler->requireTable();
34+
$output->stopList();
35+
}
36+
37+
/**
38+
* This extension hook is on DbBuild, after building the database.
39+
*/
40+
protected function onAfterBuild(PolyOutput $output): void
41+
{
42+
$sessionHandler = $this->getSessionHandler();
43+
if (!$sessionHandler) {
44+
return;
45+
}
46+
47+
$output->writeln('<options=bold>Creating table for session data</>');
48+
$output->startList();
49+
$sessionHandler->requireTable();
50+
$output->stopList();
51+
$output->writeln(['<options=bold>session database build completed!</>', '']);
52+
}
53+
54+
private function getSessionHandler(): ?DatabaseSessionHandler
55+
{
56+
$sessionHandlerServiceName = Session::config()->get('save_handler');
57+
if ($sessionHandlerServiceName === null) {
58+
return null;
59+
}
60+
$sessionHandler = Injector::inst()->get($sessionHandlerServiceName);
61+
if (!is_a($sessionHandler, DatabaseSessionHandler::class)) {
62+
return null;
63+
}
64+
return $sessionHandler;
65+
}
66+
}

0 commit comments

Comments
 (0)