Passed
Push — master ( 6c6d7e...b48c4f )
by Marwan
01:39
created

Session.php$1 ➔ __toString()   A

Complexity

Conditions 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
c 1
b 0
f 0
dl 0
loc 3
ccs 1
cts 1
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
use MAKS\Velox\App;
15
use MAKS\Velox\Frontend\View;
16
use MAKS\Velox\Frontend\HTML;
17
use MAKS\Velox\Helper\Misc;
18
19
/**
20
 * A class that offers a simple interface to work with sessions.
21
 *
22
 * Example:
23
 * ```
24
 * // start a session
25
 * Session::start();
26
 *
27
 * // check for variable availability
28
 * $someVarExists = Session::has('someVar');
29
 *
30
 * // set a session variable
31
 * Session::set('someVar', $value);
32
 *
33
 * // get a session variable
34
 * $someVar = Session::get('someVar');
35
 *
36
 * // destroy a session
37
 * Session::destroy();
38
 * ```
39
 *
40
 * @since 1.3.0
41
 * @api
42
 */
43
final class Session
44
{
45
    /**
46
     * Class constructor.
47
     *
48
     * @param int|null $expiration Session expiration time in minutes.
49
     * @param string|null $limiter Session limiter.
50
     * @param string|null $path Session save path.
51
     */
52 20
    public function __construct(?string $expiration = null, ?string $limiter = null, ?string $path = null)
53
    {
54 20
        $this->start($expiration, $limiter, $path);
0 ignored issues
show
Bug introduced by
It seems like $expiration can also be of type string; however, parameter $expiration of MAKS\Velox\Backend\Session::start() does only seem to accept integer|null, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

54
        $this->start(/** @scrutinizer ignore-type */ $expiration, $limiter, $path);
Loading history...
55 20
    }
56
57
    /**
58
     * Starts the session if it is not already started.
59
     *
60
     * @param int|null [optional] $expiration Session expiration time in minutes.
61
     * @param string|null [optional] $limiter Session limiter.
62
     * @param string|null [optional] $path Session save path.
63
     *
64
     * @return bool True if the session was started, false otherwise.
65
     */
66 27
    public static function start(?int $expiration = null, ?string $limiter = null, ?string $path = null): bool
67
    {
68 27
        $path       ??= Config::get('session.path', Config::get('global.paths.storage') . '/sessions');
69 27
        $limiter    ??= Config::get('session.cache.limiter', 'nocache');
70 27
        $expiration ??= Config::get('session.cache.expiration', 180);
71
72 27
        file_exists($path) || mkdir($path, 0744, true);
73
74 27
        session_save_path() != $path && session_save_path($path);
75 27
        session_cache_expire() != $expiration && session_cache_expire($expiration);
76 27
        session_cache_limiter() != $limiter && session_cache_limiter($limiter);
77
78 27
        $status = session_status() != PHP_SESSION_NONE || session_start(['name' => 'VELOX']);
79
80 27
        return $status;
81
    }
82
83
    /**
84
     * Destroys all of the data associated with the current session.
85
     * This method does not unset any of the global variables associated with the session, or unset the session cookie.
86
     *
87
     * @return bool True if the session was destroyed, false otherwise.
88
     */
89 1
    public static function destroy(): bool
90
    {
91 1
        return session_destroy();
92
    }
93
94
    /**
95
     * Unsets the session superglobal
96
     * This method deletes (truncates) only the variables in the session, session still exists.
97
     *
98
     * @return bool True if the session was unset, false otherwise.
99
     */
100 1
    public static function unset(): bool
101
    {
102 1
        return session_unset();
103
    }
104
105
    /**
106
     * Clears the session entirely.
107
     * This method will unset the session, destroy the session, commit (close writing) to the session, and reset the session cookie (new expiration).
108
     *
109
     * @return bool True if the session was cleared, false otherwise.
110
     */
111 1
    public static function clear(): bool
112
    {
113 1
        $name   = session_name();
114 1
        $cookie = session_get_cookie_params();
115
116 1
        setcookie($name, '', 0, $cookie['path'], $cookie['domain'], $cookie['secure'], $cookie['httponly'] ?? false);
117
        // not testable in CLI, headers already sent
118
        // @codeCoverageIgnoreStart
119
        $unset   = session_unset();
120
        $destroy = session_destroy();
121
        $commit  = session_commit();
122
123
        return (bool)($unset & $destroy & $commit);
0 ignored issues
show
Bug introduced by
Are you sure you want to use the bitwise & or did you mean &&?
Loading history...
124
        // @codeCoverageIgnoreEnd
125
    }
126
127
    /**
128
     * Checks if a value exists in the session.
129
     *
130
     * @param string $key The key to check. Dot-notation can be used with nested arrays.
131
     *
132
     * @return bool True if the key exists, false otherwise.
133
     */
134 1
    public static function has(string $key): bool
135
    {
136 1
        return Globals::getSession($key) !== null;
137
    }
138
139
    /**
140
     * Gets a value from the session.
141
     *
142
     * @param string $key The key to get. Dot-notation can be used with nested arrays.
143
     *
144
     * @return mixed The value of the key, or null if the key does not exist.
145
     */
146 1
    public static function get(string $key)
147
    {
148 1
        return Globals::getSession($key);
149
    }
150
151
    /**
152
     * Sets a value in the session.
153
     *
154
     * @param string $key The key to set. Dot-notation can be used with nested arrays.
155
     * @param mixed $value The value to set.
156
     *
157
     * @return static The current instance.
158
     */
159 1
    public static function set(string $key, $value)
160
    {
161 1
        Globals::setSession($key, $value);
162
163 1
        return new static();
164
    }
165
166
    /**
167
     * Cuts a value from the session. The value will be returned and the key will be unset from the array.
168
     *
169
     * @param string $key The key to cut. Dot-notation can be used with nested arrays.
170
     *
171
     * @return mixed The value of the key, or null if the key does not exist.
172
     */
173 1
    public static function cut(string $key)
174
    {
175 1
        return Globals::cutSession($key);
176
    }
177
178
179
    /**
180
     * Writes a flash message to the session.
181
     *
182
     * This method can be invoked without arguments, in that case a `Flash` object will be returned.
183
     * The `Flash` object has the following methods:
184
     * - `message(string $type, string $text, bool $now = false): static`: Writes a flash message to the session.
185
     * - `render(?callable $callback = null): ?string`: Renders the flash messages using the default callback or the passed one (callback will be passed: `$text`, `$type`).
186
     * The `render()` method will be called automatically if the object is casted to a string.
187
     *
188
     * The `Flash` object consists also of magic methods with the following signature:
189
     * - `{type}(string $text, bool $now = false)`
190
     * Where `{type}` is a message type like [`success`, `info`, `warning`, `error`] or any other value.
191
     * The `{type}` will also be used as a CSS class if the default rendering callback is used (camelCase will be changed to kebab-case automatically).
192
     *
193
     * @param string $type [optional] Message type.
194
     * @param string $text [optional] Message text.
195
     * @param bool $now [optional] Whether to write and make the message available for rendering immediately or wait for the next request.
196
     *
197
     * @return object
198
     */
199 1
    public static function flash(string $text = '', string $type = '', bool $now = false): object
200
    {
201 1
        self::start();
202
203 1
        static $flash = null;
204
205 1
        if ($flash === null) {
206 1
            $flash = new class {
207
                private string $name = '_flash';
208
                private array $messages = [];
209
                public function __construct()
210
                {
211 1
                    $this->messages = Globals::getSession($this->name) ?? [];
212
213 1
                    Globals::setSession($this->name, []);
214 1
                }
215
                public function __invoke()
216
                {
217 1
                    return $this->message(...func_get_args());
218
                }
219
                public function __call(string $method, array $arguments)
220
                {
221 1
                    return $this->message(
222 1
                        Misc::transform($method, 'kebab'),
223 1
                        $arguments[0] ?? '',
224 1
                        $arguments[1] ?? false
225
                    );
226
                }
227
                public function __toString()
228
                {
229 1
                    return $this->render();
230
                }
231
                public function message(string $type, string $text, bool $now = false)
232
                {
233 1
                    if ($now) {
234 1
                        $this->messages[md5(uniqid($text))] = [
235 1
                            'type' => $type,
236 1
                            'text' => $text
237
                        ];
238
239 1
                        return $this;
240
                    }
241
242 1
                    Globals::setSession($this->name . '.' . md5(uniqid($text)), [
243 1
                        'type' => $type,
244 1
                        'text' => $text
245
                    ]);
246
247 1
                    return $this;
248
                }
249
                public function render(?callable $callback = null): string
250
                {
251 1
                    $callback = $callback ?? function ($text, $type) {
252 1
                        return HTML::div($text, [
0 ignored issues
show
Bug Best Practice introduced by
The method MAKS\Velox\Frontend\HTML::div() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

252
                        return HTML::/** @scrutinizer ignore-call */ div($text, [
Loading history...
253 1
                            'class' => 'flash-message ' . $type
254
                        ]);
255 1
                    };
256
257 1
                    $html = '';
258
259 1
                    foreach ($this->messages as $message) {
260 1
                        $html .= $callback($message['text'], $message['type']);
261
                    }
262
263 1
                    return $html;
264
                }
265
            };
266
        }
267
268 1
        if (strlen(trim($text))) {
269 1
            $flash($type, $text, $now);
270
        }
271
272 1
        return $flash;
273
    }
274
275
    /**
276
     * Generates and checks CSRF tokens.
277
     *
278
     * This method will return a `CSRF` object.
279
     * The `CSRF` object has the following methods:
280
     * - `isValid(): bool`: Validate the request token with the token stored in the session.
281
     * - `token(): string`: Generates a CSRF token, stores it in the session and returns it.
282
     * - `html(): string`: Returns an HTML input element containing a CSRF token after storing it in the session.
283
     * The `html()` method will be called automatically if the object is casted to a string.
284
     *
285
     * @param string $name [optional] The name of the CSRF token. Default to "{session.csrf.name}" configuration value.
286
     * If a token name other than the default is specified, validation of this token has to be implemented manually.
287
     *
288
     * @return object
289
     */
290 7
    public static function csrf(?string $name = null): object
291
    {
292 7
        self::start();
293
294 7
        return new class ($name) {
295
            private string $name;
296
            private string $token;
297
            public function __construct(?string $name = null)
298
            {
299 7
                $this->name  = $name ?? Config::get('session.csrf.name', '_token');
300 7
                $this->token = Globals::getSession($this->name) ?? '';
301 7
            }
302
            public function __toString()
303
            {
304 1
                return $this->html();
305
            }
306
            public function token(): string
307
            {
308 1
                $this->token = empty($this->token) ? bin2hex(random_bytes(64)) : $this->token;
309
310 1
                Globals::setSession($this->name, $this->token);
311
312 1
                return $this->token;
313
            }
314
            public function html(): string
315
            {
316 1
                return HTML::input(null, [
0 ignored issues
show
Bug Best Practice introduced by
The method MAKS\Velox\Frontend\HTML::input() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

316
                return HTML::/** @scrutinizer ignore-call */ input(null, [
Loading history...
317 1
                    'type'  => 'hidden',
318 1
                    'name'  => $this->name,
319 1
                    'value' => $this->token()
320
                ]);
321
            }
322
            public function check(): void
323
            {
324 7
                if ($this->isValid()) {
325 6
                    return;
326
                }
327
328 1
                App::log('Responded with 403 to the request for "{path}". CSRF is detected. Client IP address {ip}', [
329 1
                    'uri' => Globals::getServer('REQUEST_URI'),
330 1
                    'ip'  => Globals::getServer('REMOTE_ADDR'),
331 1
                ], 'system');
332
333 1
                http_response_code(403);
334
335
                try {
336 1
                    echo View::render(Config::get('global.errorPages.403'));
0 ignored issues
show
Bug introduced by
It seems like MAKS\Velox\Backend\Confi...global.errorPages.403') can also be of type null; however, parameter $page of MAKS\Velox\Frontend\View::render() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

336
                    echo View::render(/** @scrutinizer ignore-type */ Config::get('global.errorPages.403'));
Loading history...
337 1
                    App::terminate();
338 1
                } catch (\Throwable $e) {
339 1
                    App::abort(403, null, 'Invalid CSRF token!');
340
                }
341
            }
342
            public function isValid(): bool
343
            {
344 7
                if ($this->isWhitelisted() || $this->isIdentical()) {
345 7
                    return true;
346
                }
347
348 1
                Globals::cutSession($this->name);
349
350 1
                return false;
351
            }
352
            private function isWhitelisted(): bool
353
            {
354 7
                $method = Globals::getServer('REQUEST_METHOD');
355 7
                $client = Globals::getServer('REMOTE_HOST', Globals::getServer('REMOTE_ADDR'));
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Globals::getServer() has too many arguments starting with MAKS\Velox\Backend\Globa...etServer('REMOTE_ADDR'). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

355
                /** @scrutinizer ignore-call */ 
356
                $client = Globals::getServer('REMOTE_HOST', Globals::getServer('REMOTE_ADDR'));

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
356
357
                return (
358 7
                    in_array($client, Config::get('session.csrf.whitelisted', [])) ||
359 7
                    !in_array($method, Config::get('session.csrf.methods', []))
360
                );
361
            }
362
            private function isIdentical(): bool
363
            {
364 1
                $token = Globals::cutPost($this->name, Globals::cutGet($this->name)) ?? '';
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Globals::cutPost() has too many arguments starting with MAKS\Velox\Backend\Globals::cutGet($this->name). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

364
                $token = Globals::/** @scrutinizer ignore-call */ cutPost($this->name, Globals::cutGet($this->name)) ?? '';

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
365
366 1
                return empty($this->token) || hash_equals($this->token, $token);
367
            }
368
        };
369
    }
370
}
371