Passed
Push — master ( 281bd1...9a9d98 )
by Robbie
53:25 queued 45:01
created

Session::changedData()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
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
 * An instance of a `Session` object can be retrieved via an `HTTPRequest` by calling the `getSession()` method.
13
 *
14
 * In order to support things like testing, the session is associated with a particular Controller.  In normal usage,
15
 * this is loaded from and saved to the regular PHP session, but for things like static-page-generation and
16
 * unit-testing, you can create multiple Controllers, each with their own session.
17
 *
18
 * <b>Saving Data</b>
19
 *
20
 * Once you've retrieved a session instance, you can write a value to a users session using the function {@link Session::set()}.
21
 *
22
 * <code>
23
 *  $request->getSession()->set('MyValue', 6);
24
 * </code>
25
 *
26
 * Saves the value of "6" to the MyValue session data. You can also save arrays or serialized objects in session (but
27
 * note there may be size restrictions as to how much you can save)
28
 *
29
 * <code>
30
 *
31
 *  $session = $request->getSession();
32
 *
33
 *  // save a variable
34
 *  $var = 1;
35
 *  $session->set('MyVar', $var);
36
 *
37
 *  // saves an array
38
 *  $session->set('MyArrayOfValues', array('1', '2', '3'));
39
 *
40
 *  // saves an object (you'll have to unserialize it back)
41
 *  $object = new Object();
42
 *
43
 *  $session->set('MyObject', serialize($object));
44
 * </code>
45
 *
46
 * <b>Accessing Data</b>
47
 *
48
 * Once you have saved a value to the Session you can access it by using the {@link Session::get()} function.
49
 * Note that session data isn't persisted in PHP's own session store (via $_SESSION)
50
 * until {@link Session::save()} is called, which happens automatically at the end of a standard request
51
 * through {@link SilverStripe\Control\Middleware\SessionMiddleware}.
52
 *
53
 * The values in the comments are the values stored from the previous example.
54
 *
55
 * <code>
56
 * public function bar() {
57
 *  $session = $this->getRequest()->getSession();
58
 *  $value = $session->get('MyValue'); // $value = 6
59
 *  $var   = $session->get('MyVar'); // $var = 1
60
 *  $array = $session->get('MyArrayOfValues'); // $array = array(1,2,3)
61
 *  $object = $session->get('MyObject', unserialize($object)); // $object = Object()
62
 * }
63
 * </code>
64
 *
65
 * You can also get all the values in the session at once. This is useful for debugging.
66
 *
67
 * <code>
68
 * $session->getAll(); // returns an array of all the session values.
69
 * </code>
70
 *
71
 * <b>Clearing Data</b>
72
 *
73
 * Once you have accessed a value from the Session it doesn't automatically wipe the value from the Session, you have
74
 * to specifically remove it. To clear a value you can either delete 1 session value by the name that you saved it
75
 *
76
 * <code>
77
 * $session->clear('MyValue'); // MyValue is no longer 6.
78
 * </code>
79
 *
80
 * Or you can clear every single value in the session at once. Note SilverStripe stores some of its own session data
81
 * including form and page comment information. None of this is vital but `clearAll()` will clear everything.
82
 *
83
 * <code>
84
 *  $session->clearAll();
85
 * </code>
86
 *
87
 * @see Cookie
88
 * @see HTTPRequest
89
 */
90
class Session
91
{
92
    use Configurable;
93
94
    /**
95
     * Set session timeout in seconds.
96
     *
97
     * @var int
98
     * @config
99
     */
100
    private static $timeout = 0;
0 ignored issues
show
introduced by
The private property $timeout is not used, and could be removed.
Loading history...
101
102
    /**
103
     * @config
104
     * @var array
105
     */
106
    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...
107
108
    /**
109
     * @config
110
     * @var string
111
     */
112
    private static $cookie_domain;
0 ignored issues
show
introduced by
The private property $cookie_domain is not used, and could be removed.
Loading history...
113
114
    /**
115
     * @config
116
     * @var string
117
     */
118
    private static $cookie_path;
0 ignored issues
show
introduced by
The private property $cookie_path is not used, and could be removed.
Loading history...
119
120
    /**
121
     * @config
122
     * @var string
123
     */
124
    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...
125
126
    /**
127
     * @config
128
     * @var boolean
129
     */
130
    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...
131
132
    /**
133
     * @config
134
     * @var string
135
     */
136
    private static $cookie_name_secure = 'SECSESSID';
0 ignored issues
show
introduced by
The private property $cookie_name_secure is not used, and could be removed.
Loading history...
137
138
    /**
139
     * Name of session cache limiter to use.
140
     * Defaults to '' to disable cache limiter entirely.
141
     *
142
     * @see https://secure.php.net/manual/en/function.session-cache-limiter.php
143
     * @var string|null
144
     */
145
    private static $sessionCacheLimiter = '';
0 ignored issues
show
introduced by
The private property $sessionCacheLimiter is not used, and could be removed.
Loading history...
146
147
    /**
148
     * Session data.
149
     * Will be null if session has not been started
150
     *
151
     * @var array|null
152
     */
153
    protected $data = null;
154
155
    /**
156
     * @var bool
157
     */
158
    protected $started = false;
159
160
    /**
161
     * List of keys changed. This is a nested array which represents the
162
     * keys modified in $this->data. The value of each item is either "true"
163
     * or a nested array.
164
     *
165
     * If a value is in changedData but not in data, it must be removed
166
     * from the destination during save().
167
     *
168
     * Only highest level changes are stored. E.g. changes to `Base.Sub`
169
     * and then `Base` only records `Base` as the change.
170
     *
171
     * E.g.
172
     * [
173
     *   'Base' => true,
174
     *   'Key' => [
175
     *      'Nested' => true,
176
     *   ],
177
     * ]
178
     *
179
     * @var array
180
     */
181
    protected $changedData = array();
182
183
    /**
184
     * Get user agent for this request
185
     *
186
     * @param HTTPRequest $request
187
     * @return string
188
     */
189
    protected function userAgent(HTTPRequest $request)
190
    {
191
        return $request->getHeader('User-Agent');
192
    }
193
194
    /**
195
     * Start PHP session, then create a new Session object with the given start data.
196
     *
197
     * @param array|null|Session $data Can be an array of data (such as $_SESSION) or another Session object to clone.
198
     * If null, this session is treated as unstarted.
199
     */
200
    public function __construct($data)
201
    {
202
        if ($data instanceof Session) {
203
            $data = $data->getAll();
204
        }
205
206
        $this->data = $data;
207
        $this->started = isset($data);
208
    }
209
210
    /**
211
     * Init this session instance before usage,
212
     * if a session identifier is part of the passed in request.
213
     * Otherwise, a session might be started in {@link save()}
214
     * if session data needs to be written with a new session identifier.
215
     *
216
     * @param HTTPRequest $request
217
     */
218
    public function init(HTTPRequest $request)
219
    {
220
221
        if (!$this->isStarted() && $this->requestContainsSessionId($request)) {
222
            $this->start($request);
223
        }
224
225
        // Funny business detected!
226
        if (isset($this->data['HTTP_USER_AGENT'])) {
227
            if ($this->data['HTTP_USER_AGENT'] !== $this->userAgent($request)) {
228
                $this->clearAll();
229
                $this->destroy();
230
                $this->started = false;
231
                $this->start($request);
232
            }
233
        }
234
    }
235
236
    /**
237
     * Destroy existing session and restart
238
     *
239
     * @param HTTPRequest $request
240
     */
241
    public function restart(HTTPRequest $request)
242
    {
243
        $this->destroy();
244
        $this->init($request);
245
    }
246
247
    /**
248
     * Determine if this session has started
249
     *
250
     * @return bool
251
     */
252
    public function isStarted()
253
    {
254
        return $this->started;
255
    }
256
257
    /**
258
     * @param HTTPRequest $request
259
     * @return bool
260
     */
261
    public function requestContainsSessionId(HTTPRequest $request)
262
    {
263
        $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
264
        $name = $secure ? $this->config()->get('cookie_name_secure') : session_name();
265
        return (bool)Cookie::get($name);
266
    }
267
268
    /**
269
     * Begin session, regardless if a session identifier is present in the request,
270
     * or whether any session data needs to be written.
271
     * See {@link init()} if you want to "lazy start" a session.
272
     *
273
     * @param HTTPRequest $request The request for which to start a session
274
     */
275
    public function start(HTTPRequest $request)
276
    {
277
        if ($this->isStarted()) {
278
            throw new BadMethodCallException("Session has already started");
279
        }
280
281
        $path = $this->config()->get('cookie_path');
282
        if (!$path) {
283
            $path = Director::baseURL();
284
        }
285
        $domain = $this->config()->get('cookie_domain');
286
        $secure = Director::is_https($request) && $this->config()->get('cookie_secure');
287
        $session_path = $this->config()->get('session_store_path');
288
        $timeout = $this->config()->get('timeout');
289
290
        // Director::baseURL can return absolute domain names - this extracts the relevant parts
291
        // for the session otherwise we can get broken session cookies
292
        if (Director::is_absolute_url($path)) {
293
            $urlParts = parse_url($path);
294
            $path = $urlParts['path'];
295
            if (!$domain) {
296
                $domain = $urlParts['host'];
297
            }
298
        }
299
300
        // If the session cookie is already set, then the session can be read even if headers_sent() = true
301
        // This helps with edge-case such as debugging.
302
        $data = [];
303
        if (!session_id() && (!headers_sent() || $this->requestContainsSessionId($request))) {
304
            if (!headers_sent()) {
305
                session_set_cookie_params($timeout ?: 0, $path, $domain ?: null, $secure, true);
306
307
                $limiter = $this->config()->get('sessionCacheLimiter');
308
                if (isset($limiter)) {
309
                    session_cache_limiter($limiter);
310
                }
311
312
                // Allow storing the session in a non standard location
313
                if ($session_path) {
314
                    session_save_path($session_path);
315
                }
316
317
                // If we want a secure cookie for HTTPS, use a separate session name. This lets us have a
318
                // separate (less secure) session for non-HTTPS requests
319
                // if headers_sent() is true then it's best to throw the resulting error rather than risk
320
                // a security hole.
321
                if ($secure) {
322
                    session_name($this->config()->get('cookie_name_secure'));
323
                }
324
325
                session_start();
326
            } else {
327
                // If headers are sent then we can't have a session_cache_limiter otherwise we'll get a warning
328
                session_cache_limiter(null);
329
            }
330
331
            if (isset($_SESSION)) {
332
                // Initialise data from session store if present
333
                $data = $_SESSION;
334
335
                // Merge in existing in-memory data, taking priority over session store data
336
                $this->recursivelyApply((array)$this->data, $data);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\Control\Session::recursivelyApply() has been deprecated: 4.1.0:5.0.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

336
                /** @scrutinizer ignore-deprecated */ $this->recursivelyApply((array)$this->data, $data);

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...
337
            }
338
        }
339
340
        // Save any modified session data back to the session store if present, otherwise initialise it to an array.
341
        $this->data = $data;
342
343
        $this->started = true;
344
    }
345
346
    /**
347
     * Destroy this session
348
     *
349
     * @param bool $removeCookie
350
     */
351
    public function destroy($removeCookie = true)
352
    {
353
        if (session_id()) {
354
            if ($removeCookie) {
355
                $path = $this->config()->get('cookie_path') ?: Director::baseURL();
356
                $domain = $this->config()->get('cookie_domain');
357
                $secure = $this->config()->get('cookie_secure');
358
                Cookie::force_expiry(session_name(), $path, $domain, $secure, true);
359
            }
360
            session_destroy();
361
        }
362
        // Clean up the superglobal - session_destroy does not do it.
363
        // http://nz1.php.net/manual/en/function.session-destroy.php
364
        unset($_SESSION);
365
        $this->data = null;
366
    }
367
368
    /**
369
     * Set session value
370
     *
371
     * @param string $name
372
     * @param mixed $val
373
     * @return $this
374
     */
375
    public function set($name, $val)
376
    {
377
        $var = &$this->nestedValueRef($name, $this->data);
378
379
        // Mark changed
380
        if ($var !== $val) {
381
            $var = $val;
382
            $this->markChanged($name);
383
        }
384
        return $this;
385
    }
386
387
    /**
388
     * Mark key as changed
389
     *
390
     * @internal
391
     * @param string $name
392
     */
393
    protected function markChanged($name)
394
    {
395
        $diffVar = &$this->changedData;
396
        foreach (explode('.', $name) as $namePart) {
397
            if (!isset($diffVar[$namePart])) {
398
                $diffVar[$namePart] = [];
399
            }
400
            $diffVar = &$diffVar[$namePart];
401
402
            // Already diffed
403
            if ($diffVar === true) {
404
                return;
405
            }
406
        }
407
        // Mark changed
408
        $diffVar = true;
409
    }
410
411
    /**
412
     * Merge value with array
413
     *
414
     * @param string $name
415
     * @param mixed $val
416
     */
417
    public function addToArray($name, $val)
418
    {
419
        $names = explode('.', $name);
420
421
        // We still want to do this even if we have strict path checking for legacy code
422
        $var = &$this->data;
423
        $diffVar = &$this->changedData;
424
425
        foreach ($names as $n) {
426
            $var = &$var[$n];
427
            $diffVar = &$diffVar[$n];
428
        }
429
430
        $var[] = $val;
431
        $diffVar[sizeof($var) - 1] = $val;
432
    }
433
434
    /**
435
     * Get session value
436
     *
437
     * @param string $name
438
     * @return mixed
439
     */
440
    public function get($name)
441
    {
442
        return $this->nestedValue($name, $this->data);
443
    }
444
445
    /**
446
     * Clear session value
447
     *
448
     * @param string $name
449
     * @return $this
450
     */
451
    public function clear($name)
452
    {
453
        // Get var by path
454
        $var = $this->nestedValue($name, $this->data);
455
456
        // Unset var
457
        if ($var !== null) {
458
            // Unset parent key
459
            $parentParts = explode('.', $name);
460
            $basePart = array_pop($parentParts);
461
            if ($parentParts) {
462
                $parent = &$this->nestedValueRef(implode('.', $parentParts), $this->data);
463
                unset($parent[$basePart]);
464
            } else {
465
                unset($this->data[$name]);
466
            }
467
            $this->markChanged($name);
468
        }
469
        return $this;
470
    }
471
472
    /**
473
     * Clear all values
474
     */
475
    public function clearAll()
476
    {
477
        if ($this->data && is_array($this->data)) {
478
            foreach (array_keys($this->data) as $key) {
479
                $this->clear($key);
480
            }
481
        }
482
    }
483
484
    /**
485
     * Get all values
486
     *
487
     * @return array|null
488
     */
489
    public function getAll()
490
    {
491
        return $this->data;
492
    }
493
494
    /**
495
     * Set user agent key
496
     *
497
     * @param HTTPRequest $request
498
     */
499
    public function finalize(HTTPRequest $request)
500
    {
501
        $this->set('HTTP_USER_AGENT', $this->userAgent($request));
502
    }
503
504
    /**
505
     * Save data to session
506
     * Only save the changes, so that anyone manipulating $_SESSION directly doesn't get burned.
507
     *
508
     * @param HTTPRequest $request
509
     */
510
    public function save(HTTPRequest $request)
511
    {
512
        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...
513
            $this->finalize($request);
514
515
            if (!$this->isStarted()) {
516
                $this->start($request);
517
            }
518
519
            // Apply all changes recursively, implicitly writing them to the actual PHP session store.
520
            $this->recursivelyApplyChanges($this->changedData, $this->data, $_SESSION);
521
        }
522
    }
523
524
    /**
525
     * Recursively apply the changes represented in $data to $dest.
526
     * Used to update $_SESSION
527
     *
528
     * @deprecated 4.1.0:5.0.0 Use recursivelyApplyChanges() instead
529
     * @param array $data
530
     * @param array $dest
531
     */
532
    protected function recursivelyApply($data, &$dest)
533
    {
534
        Deprecation::notice('5.0', 'Use recursivelyApplyChanges() instead');
535
        foreach ($data as $k => $v) {
536
            if (is_array($v)) {
537
                if (!isset($dest[$k]) || !is_array($dest[$k])) {
538
                    $dest[$k] = array();
539
                }
540
                $this->recursivelyApply($v, $dest[$k]);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\Control\Session::recursivelyApply() has been deprecated: 4.1.0:5.0.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

540
                /** @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...
541
            } else {
542
                $dest[$k] = $v;
543
            }
544
        }
545
    }
546
547
    /**
548
     * Returns the list of changed keys
549
     *
550
     * @return array
551
     */
552
    public function changedData()
553
    {
554
        return $this->changedData;
555
    }
556
557
    /**
558
     * Navigate to nested value in source array by name,
559
     * creating a null placeholder if it doesn't exist.
560
     *
561
     * @internal
562
     * @param string $name
563
     * @param array $source
564
     * @return mixed Reference to value in $source
565
     */
566
    protected function &nestedValueRef($name, &$source)
567
    {
568
        // Find var to change
569
        $var = &$source;
570
        foreach (explode('.', $name) as $namePart) {
571
            if (!isset($var)) {
572
                $var = [];
573
            }
574
            if (!isset($var[$namePart])) {
575
                $var[$namePart] = null;
576
            }
577
            $var = &$var[$namePart];
578
        }
579
        return $var;
580
    }
581
582
    /**
583
     * Navigate to nested value in source array by name,
584
     * returning null if it doesn't exist.
585
     *
586
     * @internal
587
     * @param string $name
588
     * @param array $source
589
     * @return mixed Value in array in $source
590
     */
591
    protected function nestedValue($name, $source)
592
    {
593
        // Find var to change
594
        $var = $source;
595
        foreach (explode('.', $name) as $namePart) {
596
            if (!isset($var[$namePart])) {
597
                return null;
598
            }
599
            $var = $var[$namePart];
600
        }
601
        return $var;
602
    }
603
604
    /**
605
     * Apply all changes using separate keys and data sources and a destination
606
     *
607
     * @internal
608
     * @param array $changes
609
     * @param array $source
610
     * @param array $destination
611
     */
612
    protected function recursivelyApplyChanges($changes, $source, &$destination)
613
    {
614
        $source = $source ?: [];
615
        foreach ($changes as $key => $changed) {
616
            if ($changed === true) {
617
                // Determine if replacement or removal
618
                if (array_key_exists($key, $source)) {
619
                    $destination[$key] = $source[$key];
620
                } else {
621
                    unset($destination[$key]);
622
                }
623
            } else {
624
                // Recursively apply
625
                $destVal = &$this->nestedValueRef($key, $destination);
626
                $sourceVal = $this->nestedValue($key, $source);
627
                $this->recursivelyApplyChanges($changed, $sourceVal, $destVal);
628
            }
629
        }
630
    }
631
}
632