Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.85% covered (warning)
83.85%
109 / 130
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilesHandler
83.85% covered (warning)
83.85%
109 / 130
46.15% covered (danger)
46.15%
6 / 13
63.40
0.00% covered (danger)
0.00%
0 / 1
 prepareConfig
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
7.11
 getFilename
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 read
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
8.60
 readData
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 write
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
8.42
 updateTimestamp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 close
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 destroy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 gc
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
6.66
 gcSubdir
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
7.01
 lock
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 unlock
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
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 Framework\Log\LogLevel;
13use Framework\Session\SaveHandler;
14use LogicException;
15use RuntimeException;
16use SensitiveParameter;
17
18/**
19 * Class FilesHandler.
20 *
21 * @package session
22 */
23class FilesHandler extends SaveHandler
24{
25    /**
26     * @var resource|null
27     */
28    protected $stream;
29
30    /**
31     * Prepare configurations to be used by the FilesHandler.
32     *
33     * @param array<string,mixed> $config Custom configs
34     *
35     * The custom configs are:
36     *
37     * ```php
38     * $configs = [
39     *     // The directory path where the session files will be saved
40     *     'directory' => '',
41     *     // A custom directory name inside the `directory` path
42     *     'prefix' => '',
43     *     // Match IP?
44     *     'match_ip' => false,
45     *     // Match User-Agent?
46     *     'match_ua' => false,
47     * ];
48     * ```
49     */
50    protected function prepareConfig(#[SensitiveParameter] array $config) : void
51    {
52        $this->config = \array_replace([
53            'prefix' => '',
54            'directory' => '',
55            'match_ip' => false,
56            'match_ua' => false,
57        ], $config);
58        if (empty($this->config['directory'])) {
59            throw new LogicException('Session config has not a directory');
60        }
61        $this->config['directory'] = \rtrim(
62            $this->config['directory'],
63            \DIRECTORY_SEPARATOR
64        ) . \DIRECTORY_SEPARATOR;
65        if ( ! \is_dir($this->config['directory'])) {
66            throw new LogicException(
67                'Session config directory does not exist: ' . $this->config['directory']
68            );
69        }
70        if ($this->config['prefix']) {
71            $dirname = $this->config['directory'] . $this->config['prefix'] . \DIRECTORY_SEPARATOR;
72            if ( ! \is_dir($dirname) && ! \mkdir($dirname, 0700) && ! \is_dir($dirname)) {
73                throw new RuntimeException(
74                    "Session prefix directory '{$dirname}' was not created",
75                );
76            }
77            $this->config['directory'] = $dirname;
78        }
79    }
80
81    /**
82     * Get the filename, using the optional
83     * match IP and match User-Agent configs.
84     *
85     * @param string $id The session id
86     *
87     * @return string The final filename
88     */
89    protected function getFilename(string $id) : string
90    {
91        $filename = $this->config['directory'] . $id[0] . $id[1] . \DIRECTORY_SEPARATOR . $id;
92        return $filename . $this->getKeySuffix();
93    }
94
95    public function open($path, $name) : bool
96    {
97        return true;
98    }
99
100    public function read($id) : string
101    {
102        if ($this->stream !== null) {
103            \rewind($this->stream);
104            return $this->readData();
105        }
106        $filename = $this->getFilename($id);
107        $dirname = \dirname($filename);
108        if ( ! \is_dir($dirname) && ! \mkdir($dirname, 0700) && ! \is_dir($dirname)) {
109            throw new RuntimeException(
110                "Session subdirectory '{$dirname}' was not created",
111            );
112        }
113        $this->sessionExists = \is_file($filename);
114        if ( ! $this->lock($filename)) {
115            return '';
116        }
117        if ( ! isset($this->sessionId)) {
118            $this->sessionId = $id;
119        }
120        if ( ! $this->sessionExists) {
121            \chmod($filename, 0600);
122            $this->setFingerprint('');
123            return '';
124        }
125        return $this->readData();
126    }
127
128    protected function readData() : string
129    {
130        $data = '';
131        while ( ! \feof($this->stream)) {
132            $data .= \fread($this->stream, 1024);
133        }
134        $this->setFingerprint($data);
135        return $data;
136    }
137
138    public function write($id, $data) : bool
139    {
140        if ( ! isset($this->stream)) {
141            return false;
142        }
143        if ($id !== $this->sessionId) {
144            $this->sessionId = $id;
145        }
146        if ($this->hasSameFingerprint($data)) {
147            return ! $this->sessionExists || \touch($this->getFilename($id));
148        }
149        if ($this->sessionExists) {
150            \ftruncate($this->stream, 0);
151            \rewind($this->stream);
152        }
153        if ($data !== '') {
154            $written = \fwrite($this->stream, $data);
155            if ($written === false) {
156                $this->log('Session (files): Unable to write data');
157                return false;
158            }
159        }
160        $this->setFingerprint($data);
161        return true;
162    }
163
164    public function updateTimestamp($id, $data) : bool
165    {
166        $filename = $this->getFilename($id);
167        return \is_file($filename)
168            ? \touch($filename)
169            : \fwrite($this->stream, $data) !== false;
170    }
171
172    public function close() : bool
173    {
174        if ( ! \is_resource($this->stream)) {
175            return true;
176        }
177        $this->unlock();
178        $this->sessionExists = false;
179        return true;
180    }
181
182    public function destroy($id) : bool
183    {
184        $this->close();
185        \clearstatcache();
186        $filename = $this->getFilename($id);
187        return ! \is_file($filename) || \unlink($filename);
188    }
189
190    public function gc($max_lifetime) : int | false
191    {
192        $dirHandle = \opendir($this->config['directory']);
193        if ($dirHandle === false) {
194            $this->log(
195                "Session (files): Garbage Collector could not open directory '{$this->config['directory']}'",
196                LogLevel::DEBUG
197            );
198            return false;
199        }
200        $gcCount = 0;
201        $max_lifetime = \time() - $max_lifetime;
202        while (($filename = \readdir($dirHandle)) !== false) {
203            if ($filename !== '.'
204                && $filename !== '..'
205                && \is_dir($this->config['directory'] . $filename)
206            ) {
207                $gcCount += $this->gcSubdir(
208                    $this->config['directory'] . $filename,
209                    $max_lifetime
210                );
211            }
212        }
213        \closedir($dirHandle);
214        return $gcCount;
215    }
216
217    protected function gcSubdir(string $directory, int $maxMtime) : int
218    {
219        $gcCount = 0;
220        $dirHandle = \opendir($directory);
221        if ($dirHandle === false) {
222            return $gcCount;
223        }
224        while (($filename = \readdir($dirHandle)) !== false) {
225            $filename = $directory . \DIRECTORY_SEPARATOR . $filename;
226            if (\is_dir($filename)) {
227                continue;
228            }
229            $mtime = \filemtime($filename);
230            if (($mtime < $maxMtime) && \unlink($filename)) {
231                $gcCount++;
232            }
233        }
234        \closedir($dirHandle);
235        if (\count((array) \scandir($directory)) === 2) {
236            \rmdir($directory);
237        }
238        return $gcCount;
239    }
240
241    protected function lock(string $id) : bool
242    {
243        $stream = \fopen($id, 'c+b');
244        if ($stream === false) {
245            return false;
246        }
247        if (\flock($stream, \LOCK_EX) === false) {
248            $this->log("Session (files): Error while trying to lock '{$id}'");
249            \fclose($stream);
250            return false;
251        }
252        $this->stream = $stream;
253        return true;
254    }
255
256    protected function unlock() : bool
257    {
258        if ($this->stream === null) {
259            return true;
260        }
261        $unlocked = \flock($this->stream, \LOCK_UN);
262        if ($unlocked === false) {
263            $this->log('Session (files): Error while trying to unlock ' . $this->getFilename($this->sessionId));
264        }
265        \fclose($this->stream);
266        $this->stream = null;
267        return true;
268    }
269}