Passed
Pull Request — 4.0 (#7825)
by Damian
05:02
created

Session::markChanged()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 8
c 0
b 0
f 0
nc 5
nop 1
dl 0
loc 16
rs 9.2
1
<?php
2
3
namespace SilverStripe\Control;
4
5
use BadMethodCallException;
6
use SilverStripe\Core\Config\Configurable;
7
use SilverStripe\Dev\Deprecation;
8
9
/**
10
 * Handles all manipulation of the session.
11
 *
12
 * The static methods are used to manipulate the currently active controller's session.
13
 * The instance methods are used to manipulate a particular session.  There can be more than one of these created.
14
 *
15
 * In order to support things like testing, the session is associated with a particular Controller.  In normal usage,
16
 * this is loaded from and saved to the regular PHP session, but for things like static-page-generation and
17
 * unit-testing, you can create multiple Controllers, each with their own session.
18
 *
19
 * The instance object is basically just a way of manipulating a set of nested maps, and isn't specific to session
20
 * data.
21
 *
22
 * <b>Saving Data</b>
23
 *
24
 * You can write a value to a users session from your PHP code using the static function {@link Session::set()}. You
25
 * can add this line in any function or file you wish to save the value.
26
 *
27
 * <code>
28
 *  Session::set('MyValue', 6);
29
 * </code>
30
 *
31
 * Saves the value of "6" to the MyValue session data. You can also save arrays or serialized objects in session (but
32
 * note there may be size restrictions as to how much you can save)
33
 *
34
 * <code>
35
 *  // save a variable
36
 *  $var = 1;
37
 *  Session::set('MyVar', $var);
38
 *
39
 *  // saves an array
40
 *  Session::set('MyArrayOfValues', array('1', '2', '3'));
41
 *
42
 *  // saves an object (you'll have to unserialize it back)
43
 *  $object = new Object();
44
 *
45
 *  Session::set('MyObject', serialize($object));
46
 * </code>
47
 *
48
 * <b>Accessing Data</b>
49
 *
50
 * Once you have saved a value to the Session you can access it by using the {@link Session::get()} function.
51
 * Like the {@link Session::set()} function you can use this anywhere in your PHP files.
52
 *
53
 * The values in the comments are the values stored from the previous example.
54
 *
55
 * <code>
56
 * public function bar() {
57
 *  $value = Session::get('MyValue'); // $value = 6
58
 *  $var   = Session::get('MyVar'); // $var = 1
59
 *  $array = Session::get('MyArrayOfValues'); // $array = array(1,2,3)
60
 *  $object = Session::get('MyObject', unserialize($object)); // $object = Object()
61
 * }
62
 * </code>
63
 *
64
 * You can also get all the values in the session at once. This is useful for debugging.
65
 *
66
 * <code>
67
 * Session::get_all(); // returns an array of all the session values.
68
 * </code>
69
 *
70
 * <b>Clearing Data</b>
71
 *
72
 * Once you have accessed a value from the Session it doesn't automatically wipe the value from the Session, you have
73
 * to specifically remove it. To clear a value you can either delete 1 session value by the name that you saved it
74
 *
75
 * <code>
76
 * Session::clear('MyValue'); // MyValue is no longer 6.
77
 * </code>
78
 *
79
 * Or you can clear every single value in the session at once. Note SilverStripe stores some of its own session data
80
 * including form and page comment information. None of this is vital but clear_all will clear everything.
81
 *
82
 * <code>
83
 *  Session::clear_all();
84
 * </code>
85
 *
86
 * @see Cookie
87
 * @todo This class is currently really basic and could do with a more well-thought-out implementation.
88
 */
89
class Session
90
{
91
    use Configurable;
92
93
    /**
94
     * Set session timeout in seconds.
95
     *
96
     * @var int
97
     * @config
98
     */
99
    private static $timeout = 0;
0 ignored issues
show
introduced by
The private property $timeout is not used, and could be removed.
Loading history...
100
101
    /**
102
     * @config
103
     * @var array
104
     */
105
    private static $session_ips = array();
0 ignored issues
show
introduced by
The private property $session_ips is not used, and could be removed.
Loading history...
106
107
    /**
108
     * @config
109
     * @var string
110
     */
111
    private static $cookie_domain;
0 ignored issues
show
introduced by
The private property $cookie_domain is not used, and could be removed.
Loading history...
112
113
    /**
114
     * @config
115
     * @var string
116
     */
117
    private static $cookie_path;
0 ignored issues
show
introduced by
The private property $cookie_path is not used, and could be removed.
Loading history...
118
119
    /**
120
     * @config
121
     * @var string
122
     */
123
    private static $session_store_path;
0 ignored issues
show
introduced by
The private property $session_store_path is not used, and could be removed.
Loading history...
124
125
    /**
126
     * @config
127
     * @var boolean
128
     */
129
    private static $cookie_secure = false;
0 ignored issues
show
introduced by
The private property $cookie_secure is not used, and could be removed.
Loading history...
130
131
    /**
132
     * Session data.
133
     * Will be null if session has not been started
134
     *
135
     * @var array|null
136
     */
137
    protected $data = null;
138
139
    /**
140
     * List of keys changed. This is a nested array which represents the
141
     * keys modified in $this->data. The value of each item is either "true"
142
     * or a nested array.
143
     *
144
     * If a value is in changedData but not in data, it must be removed
145
     * from the destination during save().
146
     *
147
     * Only highest level changes are stored. E.g. changes to `Base.Sub`
148
     * and then `Base` only records `Base` as the change.
149
     *
150
     * E.g.
151
     * [
152
     *   'Base' => true,
153
     *   'Key' => [
154
     *      'Nested' => true,
155
     *   ],
156
     * ]
157
     *
158
     * @var array
159
     */
160
    protected $changedData = array();
161
162
    /**
163
     * Get user agent for this request
164
     *
165
     * @param HTTPRequest $request
166
     * @return string
167
     */
168
    protected function userAgent(HTTPRequest $request)
169
    {
170
        return $request->getHeader('User-Agent');
171
    }
172
173
    /**
174
     * Start PHP session, then create a new Session object with the given start data.
175
     *
176
     * @param array|null|Session $data Can be an array of data (such as $_SESSION) or another Session object to clone.
177
     * If null, this session is treated as unstarted.
178
     */
179
    public function __construct($data)
180
    {
181
        if ($data instanceof Session) {
182
            $data = $data->getAll();
183
        }
184
185
        $this->data = $data;
186
    }
187
188
    /**
189
     * Init this session instance before usage
190
     *
191
     * @param HTTPRequest $request
192
     */
193
    public function init(HTTPRequest $request)
194
    {
195
        if (!$this->isStarted()) {
196
            $this->start($request);
197
        }
198
199
        // Funny business detected!
200
        if (isset($this->data['HTTP_USER_AGENT'])) {
201
            if ($this->data['HTTP_USER_AGENT'] !== $this->userAgent($request)) {
202
                $this->clearAll();
203
                $this->destroy();
204
                $this->start($request);
205
            }
206
        }
207
    }
208
209
    /**
210
     * Destroy existing session and restart
211
     *
212
     * @param HTTPRequest $request
213
     */
214
    public function restart(HTTPRequest $request)
215
    {
216
        $this->destroy();
217
        $this->init($request);
218
    }
219
220
    /**
221
     * Determine if this session has started
222
     *
223
     * @return bool
224
     */
225
    public function isStarted()
226
    {
227
        return isset($this->data);
228
    }
229
230
    /**
231
     * Begin session
232
     *
233
     * @param HTTPRequest $request The request for which to start a session
234
     */
235
    public function start(HTTPRequest $request)
236
    {
237
        if ($this->isStarted()) {
238
            throw new BadMethodCallException("Session has already started");
239
        }
240
241
        $path = $this->config()->get('cookie_path');
242
        if (!$path) {
243
            $path = Director::baseURL();
244
        }
245
        $domain = $this->config()->get('cookie_domain');
246
        $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
247
        $session_path = $this->config()->get('session_store_path');
248
        $timeout = $this->config()->get('timeout');
249
250
        // Director::baseURL can return absolute domain names - this extracts the relevant parts
251
        // for the session otherwise we can get broken session cookies
252
        if (Director::is_absolute_url($path)) {
253
            $urlParts = parse_url($path);
254
            $path = $urlParts['path'];
255
            if (!$domain) {
256
                $domain = $urlParts['host'];
257
            }
258
        }
259
260
        if (!session_id() && !headers_sent()) {
261
            if ($domain) {
262
                session_set_cookie_params($timeout, $path, $domain, $secure, true);
263
            } else {
264
                session_set_cookie_params($timeout, $path, null, $secure, true);
265
            }
266
267
            // Allow storing the session in a non standard location
268
            if ($session_path) {
269
                session_save_path($session_path);
270
            }
271
272
            // If we want a secure cookie for HTTPS, use a seperate session name. This lets us have a
273
            // seperate (less secure) session for non-HTTPS requests
274
            if ($secure) {
275
                session_name('SECSESSID');
276
            }
277
278
            session_start();
279
280
            $this->data = isset($_SESSION) ? $_SESSION : array();
281
        } else {
282
            $this->data = [];
283
        }
284
285
        // Modify the timeout behaviour so it's the *inactive* time before the session expires.
286
        // By default it's the total session lifetime
287
        if ($timeout && !headers_sent()) {
288
            Cookie::set(session_name(), session_id(), $timeout/86400, $path, $domain ? $domain
289
                : null, $secure, true);
290
        }
291
    }
292
293
    /**
294
     * Destroy this session
295
     *
296
     * @param bool $removeCookie
297
     */
298
    public function destroy($removeCookie = true)
299
    {
300
        if (session_id()) {
301
            if ($removeCookie) {
302
                $path = $this->config()->get('cookie_path') ?: Director::baseURL();
303
                $domain = $this->config()->get('cookie_domain');
304
                $secure = $this->config()->get('cookie_secure');
305
                Cookie::force_expiry(session_name(), $path, $domain, $secure, true);
306
            }
307
            session_destroy();
308
        }
309
        // Clean up the superglobal - session_destroy does not do it.
310
        // http://nz1.php.net/manual/en/function.session-destroy.php
311
        unset($_SESSION);
312
        $this->data = null;
313
    }
314
315
    /**
316
     * Set session value
317
     *
318
     * @param string $name
319
     * @param mixed $val
320
     * @return $this
321
     */
322
    public function set($name, $val)
323
    {
324
        if (!$this->isStarted()) {
325
            throw new BadMethodCallException("Session cannot be modified until it's started");
326
        }
327
        $var = &$this->nestedValueRef($name, $this->data);
328
329
        // Mark changed
330
        if ($var !== $val) {
331
            $var = $val;
332
            $this->markChanged($name);
333
        }
334
        return $this;
335
    }
336
337
    /**
338
     * Mark key as changed
339
     *
340
     * @internal
341
     * @param string $name
342
     */
343
    protected function markChanged($name)
344
    {
345
        $diffVar = &$this->changedData;
346
        foreach (explode('.', $name) as $namePart) {
347
            if (!isset($diffVar[$namePart])) {
348
                $diffVar[$namePart] = [];
349
            }
350
            $diffVar = &$diffVar[$namePart];
351
352
            // Already diffed
353
            if ($diffVar === true) {
354
                return;
355
            }
356
        }
357
        // Mark changed
358
        $diffVar = true;
359
    }
360
361
    /**
362
     * Merge value with array
363
     *
364
     * @param string $name
365
     * @param mixed $val
366
     */
367
    public function addToArray($name, $val)
368
    {
369
        if (!$this->isStarted()) {
370
            throw new BadMethodCallException("Session cannot be modified until it's started");
371
        }
372
373
        $names = explode('.', $name);
374
375
        // We still want to do this even if we have strict path checking for legacy code
376
        $var = &$this->data;
377
        $diffVar = &$this->changedData;
378
379
        foreach ($names as $n) {
380
            $var = &$var[$n];
381
            $diffVar = &$diffVar[$n];
382
        }
383
384
        $var[] = $val;
385
        $diffVar[sizeof($var)-1] = $val;
386
    }
387
388
    /**
389
     * Get session value
390
     *
391
     * @param string $name
392
     * @return mixed
393
     */
394
    public function get($name)
395
    {
396
        if (!$this->isStarted()) {
397
            throw new BadMethodCallException("Session cannot be accessed until it's started");
398
        }
399
        return $this->nestedValue($name, $this->data);
400
    }
401
402
    /**
403
     * Clear session value
404
     *
405
     * @param string $name
406
     * @return $this
407
     */
408
    public function clear($name)
409
    {
410
        if (!$this->isStarted()) {
411
            throw new BadMethodCallException("Session cannot be modified until it's started");
412
        }
413
414
        // Get var by path
415
        $var = $this->nestedValue($name, $this->data);
416
417
        // Unset var
418
        if ($var !== null) {
419
            // Unset parent key
420
            $parentParts = explode('.', $name);
421
            $basePart = array_pop($parentParts);
422
            if ($parentParts) {
0 ignored issues
show
introduced by
The condition $parentParts can never be true.
Loading history...
Bug Best Practice introduced by
The expression $parentParts of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
423
                $parent = &$this->nestedValueRef(implode('.', $parentParts), $this->data);
424
                unset($parent[$basePart]);
425
            } else {
426
                unset($this->data[$name]);
427
            }
428
            $this->markChanged($name);
429
        }
430
        return $this;
431
    }
432
433
    /**
434
     * Clear all values
435
     */
436
    public function clearAll()
437
    {
438
        if (!$this->isStarted()) {
439
            throw new BadMethodCallException("Session cannot be modified until it's started");
440
        }
441
442
        if ($this->data && is_array($this->data)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
443
            foreach (array_keys($this->data) as $key) {
444
                $this->clear($key);
445
            }
446
        }
447
    }
448
449
    /**
450
     * Get all values
451
     *
452
     * @return array|null
453
     */
454
    public function getAll()
455
    {
456
        return $this->data;
457
    }
458
459
    /**
460
     * Set user agent key
461
     *
462
     * @param HTTPRequest $request
463
     */
464
    public function finalize(HTTPRequest $request)
465
    {
466
        $this->set('HTTP_USER_AGENT', $this->userAgent($request));
467
    }
468
469
    /**
470
     * Save data to session
471
     * Only save the changes, so that anyone manipulating $_SESSION directly doesn't get burned.
472
     *
473
     * @param HTTPRequest $request
474
     */
475
    public function save(HTTPRequest $request)
476
    {
477
        if ($this->changedData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->changedData of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
478
            $this->finalize($request);
479
480
            if (!$this->isStarted()) {
481
                $this->start($request);
482
            }
483
484
            // Apply all changes recursively
485
            $this->recursivelyApplyChanges($this->changedData, $this->data, $_SESSION);
486
        }
487
    }
488
489
    /**
490
     * Recursively apply the changes represented in $data to $dest.
491
     * Used to update $_SESSION
492
     *
493
     * @deprecated 4.1...5.0 Use recursivelyApplyChanges() instead
494
     * @param array $data
495
     * @param array $dest
496
     */
497
    protected function recursivelyApply($data, &$dest)
498
    {
499
        Deprecation::notice('5.0', 'Use recursivelyApplyChanges() instead');
500
        foreach ($data as $k => $v) {
501
            if (is_array($v)) {
502
                if (!isset($dest[$k]) || !is_array($dest[$k])) {
503
                    $dest[$k] = array();
504
                }
505
                $this->recursivelyApply($v, $dest[$k]);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\Control\Session::recursivelyApply() has been deprecated: 4.1...5.0 Use recursivelyApplyChanges() instead ( Ignorable by Annotation )

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

505
                /** @scrutinizer ignore-deprecated */ $this->recursivelyApply($v, $dest[$k]);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
506
            } else {
507
                $dest[$k] = $v;
508
            }
509
        }
510
    }
511
512
    /**
513
     * Returns the list of changed keys
514
     *
515
     * @return array
516
     */
517
    public function changedData()
518
    {
519
        return $this->changedData;
520
    }
521
522
    /**
523
     * Navigate to nested value in source array by name,
524
     * creating a null placeholder if it doesn't exist.
525
     *
526
     * @internal
527
     * @param string $name
528
     * @param array $source
529
     * @return mixed Reference to value in $source
530
     */
531
    protected function &nestedValueRef($name, &$source)
532
    {
533
        // Find var to change
534
        $var = &$source;
535
        foreach (explode('.', $name) as $namePart) {
536
            if (!isset($var)) {
537
                $var = [];
538
            }
539
            if (!isset($var[$namePart])) {
540
                $var[$namePart] = null;
541
            }
542
            $var = &$var[$namePart];
543
        }
544
        return $var;
545
    }
546
547
    /**
548
     * Navigate to nested value in source array by name,
549
     * returning null if it doesn't exist.
550
     *
551
     * @internal
552
     * @param string $name
553
     * @param array $source
554
     * @return mixed Value in array in $source
555
     */
556
    protected function nestedValue($name, $source)
557
    {
558
        // Find var to change
559
        $var = $source;
560
        foreach (explode('.', $name) as $namePart) {
561
            if (!isset($var[$namePart])) {
562
                return null;
563
            }
564
            $var = $var[$namePart];
565
        }
566
        return $var;
567
    }
568
569
    /**
570
     * Apply all changes using separate keys and data sources and a destination
571
     *
572
     * @internal
573
     * @param array $changes
574
     * @param array $source
575
     * @param array $destination
576
     */
577
    protected function recursivelyApplyChanges($changes, $source, &$destination)
578
    {
579
        foreach ($changes as $key => $changed) {
580
            if ($changed === true) {
581
                // Determine if replacement or removal
582
                if (array_key_exists($key, $source)) {
583
                    $destination[$key] = $source[$key];
584
                } else {
585
                    unset($destination[$key]);
586
                }
587
            } else {
588
                // Recursively apply
589
                $destVal = &$this->nestedValueRef($key, $destination);
590
                $sourceVal = $this->nestedValue($key, $source);
591
                $this->recursivelyApplyChanges($changed, $sourceVal, $destVal);
592
            }
593
        }
594
    }
595
}
596