Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.42% covered (success)
99.42%
172 / 173
94.44% covered (success)
94.44%
17 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseHandler
99.42% covered (success)
99.42%
172 / 173
94.44% covered (success)
94.44%
17 / 18
43
0.00% covered (danger)
0.00%
0 / 1
 prepareConfig
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 setDatabase
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDatabase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addWhereMatchs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 addUserIdColumn
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 open
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 read
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 write
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 writeInsert
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 writeUpdate
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 updateTimestamp
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 destroy
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 gc
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 lock
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 unlock
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Session Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Session\SaveHandlers;
11
12use Closure;
13use Framework\Database\Database;
14use Framework\Database\Manipulation\Delete;
15use Framework\Database\Manipulation\Select;
16use Framework\Database\Manipulation\Update;
17use Framework\Log\LogLevel;
18use Framework\Session\SaveHandler;
19use SensitiveParameter;
20
21/**
22 * Class DatabaseHandler.
23 *
24 * ```sql
25 * CREATE TABLE `Sessions` (
26 *     `id` varchar(128) NOT NULL,
27 *     `timestamp` timestamp NOT NULL,
28 *     `data` blob NOT NULL,
29 *     `ip` varchar(45) NOT NULL, -- optional
30 *     `ua` varchar(255) NOT NULL, -- optional
31 *     PRIMARY KEY (`id`),
32 *     KEY `timestamp` (`timestamp`),
33 *     KEY `ip` (`ip`), -- optional
34 *     KEY `ua` (`ua`) -- optional
35 * );
36 * ```
37 *
38 * @package session
39 */
40class DatabaseHandler extends SaveHandler
41{
42    protected ?Database $database;
43
44    /**
45     * Prepare configurations to be used by the DatabaseHandler.
46     *
47     * @param array<string,mixed> $config Custom configs
48     *
49     * The custom configs are:
50     *
51     * ```php
52     * $configs = [
53     *     // The name of the table used for sessions
54     *     'table' => 'Sessions',
55     *     // The maxlifetime used for locking
56     *     'maxlifetime' => null, // Null to use the ini value of session.gc_maxlifetime
57     *     // The custom column names as values
58     *     'columns' => [
59     *         'id' => 'id',
60     *         'data' => 'data',
61     *         'timestamp' => 'timestamp',
62     *         'ip' => 'ip',
63     *         'ua' => 'ua',
64     *     ],
65     *     // Match IP?
66     *     'match_ip' => false,
67     *     // Match User-Agent?
68     *     'match_ua' => false,
69     *     // Independent of match_ip, save the initial IP in the ip column?
70     *     'save_ip' => false,
71     *     // Independent of match_ua, save the initial User-Agent in the ua column?
72     *     'save_ua' => false,
73     * ];
74     * ```
75     *
76     * NOTE: The Database::connect configs was not shown.
77     */
78    protected function prepareConfig(#[SensitiveParameter] array $config) : void
79    {
80        $this->config = \array_replace_recursive([
81            'table' => 'Sessions',
82            'maxlifetime' => null,
83            'columns' => [
84                'id' => 'id',
85                'data' => 'data',
86                'timestamp' => 'timestamp',
87                'ip' => 'ip',
88                'ua' => 'ua',
89                'user_id' => 'user_id',
90            ],
91            'match_ip' => false,
92            'match_ua' => false,
93            'save_ip' => false,
94            'save_ua' => false,
95            'save_user_id' => false,
96        ], $config);
97    }
98
99    public function setDatabase(Database $database) : static
100    {
101        $this->database = $database;
102        return $this;
103    }
104
105    public function getDatabase() : ?Database
106    {
107        return $this->database ?? null;
108    }
109
110    /**
111     * Get the table name based on custom/default configs.
112     *
113     * @return string The table name
114     */
115    protected function getTable() : string
116    {
117        return $this->config['table'];
118    }
119
120    /**
121     * Get a column name based on custom/default configs.
122     *
123     * @param string $key The columns config key
124     *
125     * @return string The column name
126     */
127    protected function getColumn(string $key) : string
128    {
129        return $this->config['columns'][$key];
130    }
131
132    /**
133     * Adds the `WHERE $column = $value` clauses when matching IP or User-Agent.
134     *
135     * @param Delete|Select|Update $statement The statement to add the WHERE clause
136     */
137    protected function addWhereMatchs(Delete | Select | Update $statement) : void
138    {
139        if ($this->config['match_ip']) {
140            $statement->whereEqual($this->getColumn('ip'), $this->getIP());
141        }
142        if ($this->config['match_ua']) {
143            $statement->whereEqual($this->getColumn('ua'), $this->getUA());
144        }
145    }
146
147    /**
148     * Adds the optional `user_id` column.
149     *
150     * @param array<string,Closure|string> $columns The statement columns to insert/update
151     */
152    protected function addUserIdColumn(array &$columns) : void
153    {
154        if ($this->config['save_user_id']) {
155            $key = $this->getColumn('user_id');
156            $columns[$key] = $_SESSION[$key] ?? null;
157        }
158    }
159
160    public function open($path, $name) : bool
161    {
162        try {
163            $this->database ??= new Database($this->config);
164            return true;
165        } catch (\Exception $exception) {
166            $this->log(
167                'Session (database): Thrown a ' . \get_class($exception)
168                . ' while trying to open: ' . $exception->getMessage()
169            );
170        }
171        return false;
172    }
173
174    public function read($id) : string
175    {
176        if ( ! isset($this->database) || $this->lock($id) === false) {
177            $this->setFingerprint('');
178            return '';
179        }
180        if ( ! isset($this->sessionId)) {
181            $this->sessionId = $id;
182        }
183        $statement = $this->database
184            ->select()
185            ->from($this->getTable())
186            ->whereEqual($this->getColumn('id'), $id);
187        $this->addWhereMatchs($statement);
188        $row = $statement->limit(1)->run()->fetch();
189        $this->sessionExists = (bool) $row;
190        $data = $row->data ?? '';
191        $this->setFingerprint($data);
192        return $data;
193    }
194
195    public function write($id, $data) : bool
196    {
197        if ( ! isset($this->database)) {
198            return false;
199        }
200        if ($this->lockId === false) {
201            return false;
202        }
203        if ($id !== $this->sessionId) {
204            $this->sessionExists = false;
205            $this->sessionId = $id;
206        }
207        if ($this->sessionExists) {
208            return $this->writeUpdate($id, $data);
209        }
210        return $this->writeInsert($id, $data);
211    }
212
213    protected function writeInsert(string $id, string $data) : bool
214    {
215        $columns = [
216            $this->getColumn('id') => $id,
217            $this->getColumn('timestamp') => static function () : string {
218                return 'NOW()';
219            },
220            $this->getColumn('data') => $data,
221        ];
222        if ($this->config['match_ip'] || $this->config['save_ip']) {
223            $columns[$this->getColumn('ip')] = $this->getIP();
224        }
225        if ($this->config['match_ua'] || $this->config['save_ua']) {
226            $columns[$this->getColumn('ua')] = $this->getUA();
227        }
228        $this->addUserIdColumn($columns);
229        $inserted = $this->database
230            ->insert($this->getTable())
231            ->set($columns)
232            ->run();
233        if ($inserted === 0) {
234            return false;
235        }
236        $this->setFingerprint($data);
237        $this->sessionExists = true;
238        return true;
239    }
240
241    protected function writeUpdate(string $id, string $data) : bool
242    {
243        $columns = [
244            $this->getColumn('timestamp') => static function () : string {
245                return 'NOW()';
246            },
247        ];
248        if ( ! $this->hasSameFingerprint($data)) {
249            $columns[$this->getColumn('data')] = $data;
250        }
251        $this->addUserIdColumn($columns);
252        $statement = $this->database
253            ->update()
254            ->table($this->getTable())
255            ->set($columns)
256            ->whereEqual($this->getColumn('id'), $id);
257        $this->addWhereMatchs($statement);
258        $statement->run();
259        return true;
260    }
261
262    public function updateTimestamp($id, $data) : bool
263    {
264        $statement = $this->database
265            ->update()
266            ->table($this->getTable())
267            ->set([
268                $this->getColumn('timestamp') => static function () : string {
269                    return 'NOW()';
270                },
271            ])
272            ->whereEqual($this->getColumn('id'), $id);
273        $this->addWhereMatchs($statement);
274        $statement->run();
275        return true;
276    }
277
278    public function close() : bool
279    {
280        $closed = ! ($this->lockId && ! $this->unlock());
281        $this->database = null;
282        return $closed;
283    }
284
285    public function destroy($id) : bool
286    {
287        $statement = $this->database
288            ->delete()
289            ->from($this->getTable())
290            ->whereEqual($this->getColumn('id'), $id);
291        $this->addWhereMatchs($statement);
292        $result = $statement->run();
293        if ($result !== 1) {
294            $this->log(
295                'Session (database): Expected to delete 1 row, deleted ' . $result,
296                LogLevel::DEBUG
297            );
298        }
299        return true;
300    }
301
302    public function gc($max_lifetime) : int | false
303    {
304        try {
305            $this->database ??= new Database($this->config);
306        } catch (\Exception $exception) {
307            $this->log(
308                'Session (database): Thrown a ' . \get_class($exception)
309                . ' while trying to gc: ' . $exception->getMessage()
310            );
311            return false;
312        }
313        // @phpstan-ignore-next-line
314        return $this->database
315            ->delete()
316            ->from($this->getTable())
317            ->whereLessThan(
318                $this->getColumn('timestamp'),
319                static function () use ($max_lifetime) : string {
320                    return 'NOW() - INTERVAL ' . $max_lifetime . ' second';
321                }
322            )->run();
323    }
324
325    protected function lock(string $id) : bool
326    {
327        $row = $this->database
328            ->select()
329            ->expressions([
330                'locked' => function (Database $database) use ($id) : string {
331                    $id = $database->quote($id);
332                    $maxlifetime = $database->quote($this->getMaxlifetime());
333                    return "GET_LOCK({$id}{$maxlifetime})";
334                },
335            ])->run()
336            ->fetch();
337        if ($row && $row->locked) { // @phpstan-ignore-line
338            $this->lockId = $id;
339            return true;
340        }
341        $this->log('Session (database): Error while trying to lock ' . $id);
342        return false;
343    }
344
345    protected function unlock() : bool
346    {
347        if ($this->lockId === false) {
348            return true;
349        }
350        $row = $this->database
351            ->select()
352            ->expressions([
353                'unlocked' => function (Database $database) : string {
354                    $lockId = $database->quote($this->lockId);
355                    return "RELEASE_LOCK({$lockId})";
356                },
357            ])->run()
358            ->fetch();
359        if ($row && $row->unlocked) { // @phpstan-ignore-line
360            $this->lockId = false;
361            return true;
362        }
363        $this->log('Session (database): Error while trying to unlock ' . $this->lockId);
364        return false;
365    }
366}