-
Notifications
You must be signed in to change notification settings - Fork 283
/
Connector.php
645 lines (567 loc) · 17.9 KB
/
Connector.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
<?php
namespace PhpConsole;
/**
* PHP Console client connector that encapsulates client-server protocol implementation
*
* You will need to install Google Chrome extension "PHP Console"
* https://chrome.google.com/webstore/detail/php-console/nfhmhhlpfleoednkpnnnkolmclajemef
*
* @package PhpConsole
* @version 3.1
* @link http://consle.com
* @author Sergey Barbushin http://linkedin.com/in/barbushin
* @copyright © Sergey Barbushin, 2011-2013. All rights reserved.
* @license http://www.opensource.org/licenses/BSD-3-Clause "The BSD 3-Clause License"
* @codeCoverageIgnore
*/
class Connector {
const SERVER_PROTOCOL = 5;
const SERVER_COOKIE = 'php-console-server';
const CLIENT_INFO_COOKIE = 'php-console-client';
const CLIENT_ENCODING = 'UTF-8';
const HEADER_NAME = 'PHP-Console';
const POSTPONE_HEADER_NAME = 'PHP-Console-Postpone';
const POST_VAR_NAME = '__PHP_Console';
const POSTPONE_REQUESTS_LIMIT = 10;
const PHP_HEADERS_SIZE = 1000; // maximum PHP response headers size
const CLIENT_HEADERS_LIMIT = 200000;
/** @var Connector */
protected static $instance;
/** @var Storage|null */
private static $postponeStorage;
/** @var Dumper|null */
protected $dumper;
/** @var Dispatcher\Debug|null */
protected $debugDispatcher;
/** @var Dispatcher\Errors|null */
protected $errorsDispatcher;
/** @var Dispatcher\Evaluate|null */
protected $evalDispatcher;
/** @var string */
protected $serverEncoding = self::CLIENT_ENCODING;
protected $sourcesBasePath;
protected $headersLimit;
/** @var Client|null */
private $client;
/** @var Auth|null */
private $auth;
/** @var Message[] */
private $messages = array();
private $postponeResponseId;
private $isSslOnlyMode = false;
private $isActiveClient = false;
private $isAuthorized = false;
private $isEvalListenerStarted = false;
private $registeredShutDowns = 0;
/**
* @return static
*/
public static function getInstance() {
if(!self::$instance) {
self::$instance = new static();
}
return self::$instance;
}
/**
* Set storage for postponed response data. Storage\Session is used by default, but if you have problems with overridden session handler you should use another one.
* IMPORTANT: This method cannot be called after Connector::getInstance()
* @param Storage $storage
* @throws \Exception
*/
public static function setPostponeStorage(Storage $storage) {
if(self::$instance) {
throw new \Exception(__METHOD__ . ' can be called only before ' . __CLASS__ . '::getInstance()');
}
self::$postponeStorage = $storage;
}
/**
* @return Storage
*/
private function getPostponeStorage() {
if(!self::$postponeStorage) {
self::$postponeStorage = new Storage\Session();
}
return self::$postponeStorage;
}
protected function __construct() {
$this->initConnection();
$this->setServerEncoding(ini_get('mbstring.internal_encoding') ? : self::CLIENT_ENCODING);
}
private final function __clone() {
}
/**
* Detect script is running in command-line mode
* @return int
*/
protected function isCliMode() {
return PHP_SAPI == 'cli';
}
/**
* Notify clients that there is active PHP Console on server & check if there is request from client with active PHP Console
* @throws \Exception
*/
private function initConnection() {
if($this->isCliMode()) {
return;
}
$this->initServerCookie();
$this->client = $this->initClient();
if($this->client) {
ob_start();
$this->isActiveClient = true;
$this->registerFlushOnShutDown();
$this->setHeadersLimit(isset($_SERVER['SERVER_SOFTWARE']) && stripos($_SERVER['SERVER_SOFTWARE'], 'nginx') !== false
? 4096 // default headers limit for Nginx
: 8192 // default headers limit for all other web-servers
);
$this->listenGetPostponedResponse();
$this->postponeResponseId = $this->setPostponeHeader();
}
}
/**
* Get connected client data(
* @return Client|null
* @throws \Exception
*/
private function initClient() {
if(isset($_COOKIE[self::CLIENT_INFO_COOKIE])) {
$clientData = @json_decode(base64_decode($_COOKIE[self::CLIENT_INFO_COOKIE], true), true);
if(!$clientData) {
throw new \Exception('Wrong format of response cookie data: ' . $_COOKIE[self::CLIENT_INFO_COOKIE]);
}
$client = new Client($clientData);
if(isset($clientData['auth'])) {
$client->auth = new ClientAuth($clientData['auth']);
}
return $client;
}
}
/**
* Notify clients that there is active PHP Console on server
* @throws \Exception
*/
private function initServerCookie() {
if(!isset($_COOKIE[self::SERVER_COOKIE]) || $_COOKIE[self::SERVER_COOKIE] != self::SERVER_PROTOCOL) {
$isSuccess = setcookie(self::SERVER_COOKIE, self::SERVER_PROTOCOL, null, '/');
if(!$isSuccess) {
throw new \Exception('Unable to set PHP Console server cookie');
}
}
}
/**
* Check if there is client is installed PHP Console extension
* @return bool
*/
public function isActiveClient() {
return $this->isActiveClient;
}
/**
* Set client connection as not active
*/
public function disable() {
$this->isActiveClient = false;
}
/**
* Check if client with valid auth credentials is connected
* @return bool
*/
public function isAuthorized() {
return $this->isAuthorized;
}
/**
* Set IP masks of clients that will be allowed to connect to PHP Console
* @param array $ipMasks Use *(star character) for "any numbers" placeholder array('192.168.*.*', '10.2.12*.*', '127.0.0.1', '2001:0:5ef5:79fb:*:*:*:*')
*/
public function setAllowedIpMasks(array $ipMasks) {
if($this->isActiveClient()) {
if(isset($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
foreach($ipMasks as $ipMask) {
if(preg_match('~^' . str_replace(array('.', '*'), array('\.', '\w+'), $ipMask) . '$~i', $ip)) {
return;
}
}
}
$this->disable();
}
}
/**
* @return Dumper
*/
public function getDumper() {
if(!$this->dumper) {
$this->dumper = new Dumper();
}
return $this->dumper;
}
/**
* Override default errors dispatcher
* @param Dispatcher\Errors $dispatcher
*/
public function setErrorsDispatcher(Dispatcher\Errors $dispatcher) {
$this->errorsDispatcher = $dispatcher;
}
/**
* Get dispatcher responsible for sending errors/exceptions messages
* @return Dispatcher\Errors
*/
public function getErrorsDispatcher() {
if(!$this->errorsDispatcher) {
$this->errorsDispatcher = new Dispatcher\Errors($this, $this->getDumper());
}
return $this->errorsDispatcher;
}
/**
* Override default debug dispatcher
* @param Dispatcher\Debug $dispatcher
*/
public function setDebugDispatcher(Dispatcher\Debug $dispatcher) {
$this->debugDispatcher = $dispatcher;
}
/**
* Get dispatcher responsible for sending debug messages
* @return Dispatcher\Debug
*/
public function getDebugDispatcher() {
if(!$this->debugDispatcher) {
$this->debugDispatcher = new Dispatcher\Debug($this, $this->getDumper());
}
return $this->debugDispatcher;
}
/**
* Override default eval requests dispatcher
* @param Dispatcher\Evaluate $dispatcher
*/
public function setEvalDispatcher(Dispatcher\Evaluate $dispatcher) {
$this->evalDispatcher = $dispatcher;
}
/**
* Get dispatcher responsible for handling eval requests
* @return Dispatcher\Evaluate
*/
public function getEvalDispatcher() {
if(!$this->evalDispatcher) {
$this->evalDispatcher = new Dispatcher\Evaluate($this, new EvalProvider(), $this->getDumper());
}
return $this->evalDispatcher;
}
/**
* Enable eval request to be handled by eval dispatcher. Must be called after all Connector configurations.
* Connector::getInstance()->setPassword() is required to be called before this method
* Use Connector::getInstance()->setAllowedIpMasks() for additional access protection
* Check Connector::getInstance()->getEvalDispatcher()->getEvalProvider() to customize eval accessibility & security options
* @param bool $exitOnEval
* @param bool $flushDebugMessages Clear debug messages handled before this method is called
* @throws \Exception
*/
public function startEvalRequestsListener($exitOnEval = true, $flushDebugMessages = true) {
if(!$this->auth) {
throw new \Exception('Eval dispatcher is allowed only in password protected mode. See PhpConsole\Connector::getInstance()->setPassword(...)');
}
if($this->isEvalListenerStarted) {
throw new \Exception('Eval requests listener already started');
}
$this->isEvalListenerStarted = true;
if($this->isActiveClient() && $this->isAuthorized() && isset($_POST[Connector::POST_VAR_NAME]['eval'])) {
$request = $_POST[Connector::POST_VAR_NAME]['eval'];
if(!isset($request['data']) || !isset($request['signature'])) {
throw new \Exception('Wrong PHP Console eval request');
}
if($this->auth->getSignature($request['data']) !== $request['signature']) {
throw new \Exception('Wrong PHP Console eval request signature');
}
if($flushDebugMessages) {
foreach($this->messages as $i => $message) {
if($message instanceof DebugMessage) {
unset($this->messages[$i]);
}
}
}
$this->convertEncoding($request['data'], $this->serverEncoding, self::CLIENT_ENCODING);
$this->getEvalDispatcher()->dispatchCode($request['data']);
if($exitOnEval) {
exit;
}
}
}
/**
* Set bath to base dir of project source code(so it will be stripped in paths displaying on client)
* @param $sourcesBasePath
* @throws \Exception
*/
public function setSourcesBasePath($sourcesBasePath) {
$sourcesBasePath = realpath($sourcesBasePath);
if(!$sourcesBasePath) {
throw new \Exception('Path "' . $sourcesBasePath . '" not found');
}
$this->sourcesBasePath = $sourcesBasePath;
}
/**
* Protect PHP Console connection by password
*
* Use Connector::getInstance()->setAllowedIpMasks() for additional secure
* @param string $password
* @param bool $publicKeyByIp Set authorization token depending on client IP
* @throws \Exception
*/
public function setPassword($password, $publicKeyByIp = true) {
if($this->auth) {
throw new \Exception('Password already defined');
}
$this->convertEncoding($password, self::CLIENT_ENCODING, $this->serverEncoding);
$this->auth = new Auth($password, $publicKeyByIp);
if($this->client) {
$this->isAuthorized = $this->client->auth && $this->auth->isValidAuth($this->client->auth);
}
}
/**
* Encode var to JSON with errors & encoding handling
* @param $var
* @return string
* @throws \Exception
*/
protected function jsonEncode($var) {
return json_encode($var, defined('JSON_UNESCAPED_UNICODE') ? JSON_UNESCAPED_UNICODE : null);
}
/**
* Recursive var data encoding conversion
* @param $data
* @param $fromEncoding
* @param $toEncoding
*/
protected function convertArrayEncoding(&$data, $toEncoding, $fromEncoding) {
array_walk_recursive($data, array($this, 'convertWalkRecursiveItemEncoding'), array($toEncoding, $fromEncoding));
}
/**
* Encoding conversion callback for array_walk_recursive()
* @param string $string
* @param null $key
* @param array $args
*/
protected function convertWalkRecursiveItemEncoding(&$string, $key = null, array $args) {
$this->convertEncoding($string, $args[0], $args[1]);
}
/**
* Convert string encoding
* @param string $string
* @param string $toEncoding
* @param string|null $fromEncoding
* @throws \Exception
*/
protected function convertEncoding(&$string, $toEncoding, $fromEncoding) {
if($string && is_string($string) && $toEncoding != $fromEncoding) {
static $isMbString;
if($isMbString === null) {
$isMbString = extension_loaded('mbstring');
}
if($isMbString) {
$string = @mb_convert_encoding($string, $toEncoding, $fromEncoding) ? : $string;
}
else {
$string = @iconv($fromEncoding, $toEncoding . '//IGNORE', $string) ? : $string;
}
if(!$string && $toEncoding == 'UTF-8') {
$string = utf8_encode($string);
}
}
}
/**
* Set headers size limit for your web-server. You can auto-detect headers size limit by /examples/utils/detect_headers_limit.php
* @param $bytes
* @throws \Exception
*/
public function setHeadersLimit($bytes) {
if($bytes < static::PHP_HEADERS_SIZE) {
throw new \Exception('Headers limit cannot be less then ' . __CLASS__ . '::PHP_HEADERS_SIZE');
}
$bytes -= static::PHP_HEADERS_SIZE;
$this->headersLimit = $bytes < static::CLIENT_HEADERS_LIMIT ? $bytes : static::CLIENT_HEADERS_LIMIT;
}
/**
* Set your server PHP internal encoding, if it's different from "mbstring.internal_encoding" or UTF-8
* @param $encoding
*/
public function setServerEncoding($encoding) {
if($encoding == 'utf8' || $encoding == 'utf-8') {
$encoding = 'UTF-8'; // otherwise mb_convert_encoding() sometime fails with error(thanks to @alexborisov)
}
$this->serverEncoding = $encoding;
}
/**
* Send data message to PHP Console client(if it's connected)
* @param Message $message
*/
public function sendMessage(Message $message) {
if($this->isActiveClient()) {
$this->messages[] = $message;
}
}
/**
* Register shut down callback handler. Must be called after all errors handlers register_shutdown_function()
*/
public function registerFlushOnShutDown() {
$this->registeredShutDowns++;
register_shutdown_function(array($this, 'onShutDown'));
}
/**
* This method must be called only by register_shutdown_function(). Never call it manually!
*/
public function onShutDown() {
$this->registeredShutDowns--;
if(!$this->registeredShutDowns) {
$this->proceedResponsePackage();
}
}
/**
* Force connection by SSL for clients with PHP Console installed
*/
public function enableSslOnlyMode() {
$this->isSslOnlyMode = true;
}
/**
* Check if client is connected by SSL
* @return bool
*/
protected function isSsl() {
return (isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on') || (isset($_SERVER['SERVER_PORT']) && ($_SERVER['SERVER_PORT']) == 443);
}
/**
* Send response data to client
* @throws \Exception
*/
private function proceedResponsePackage() {
if($this->isActiveClient()) {
$response = new Response();
$response->isSslOnlyMode = $this->isSslOnlyMode;
if(isset($_POST[self::POST_VAR_NAME]['getBackData'])) {
$response->getBackData = $_POST[self::POST_VAR_NAME]['getBackData'];
}
if(!$this->isSslOnlyMode || $this->isSsl()) {
if($this->auth) {
$response->auth = $this->auth->getServerAuthStatus($this->client->auth);
}
if(!$this->auth || $this->isAuthorized()) {
$response->isLocal = isset($_SERVER['REMOTE_ADDR']) && ($_SERVER['REMOTE_ADDR'] == '127.0.0.1' || $_SERVER['REMOTE_ADDR'] == '::1');
$response->docRoot = isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : null;
$response->sourcesBasePath = $this->sourcesBasePath;
$response->isEvalEnabled = $this->isEvalListenerStarted;
$response->messages = $this->messages;
}
}
$responseData = $this->serializeResponse($response);
if(strlen($responseData) > $this->headersLimit || !$this->setHeaderData($responseData, self::HEADER_NAME, false)) {
$this->getPostponeStorage()->push($this->postponeResponseId, $responseData);
}
}
}
private function setPostponeHeader() {
$postponeResponseId = mt_rand() . mt_rand() . mt_rand();
$this->setHeaderData($this->serializeResponse(
new PostponedResponse(array(
'id' => $postponeResponseId
))
), self::POSTPONE_HEADER_NAME, true);
return $postponeResponseId;
}
private function setHeaderData($responseData, $headerName, $throwException = true) {
if(headers_sent($file, $line)) {
if($throwException) {
throw new \Exception('Unable to process response data, headers already sent in ' . $file . ':' . $line . '. Try to use ob_start() and don\'t use flush().');
}
return false;
}
header($headerName . ': ' . $responseData);
return true;
}
protected function objectToArray(&$var) {
if(is_object($var)) {
$var = get_object_vars($var);
array_walk_recursive($var, array($this, 'objectToArray'));
}
}
protected function serializeResponse(DataObject $response) {
if($this->serverEncoding != self::CLIENT_ENCODING) {
$this->objectToArray($response);
$this->convertArrayEncoding($response, self::CLIENT_ENCODING, $this->serverEncoding);
}
return $this->jsonEncode($response);
}
/**
* Check if there is postponed response request and dispatch it
*/
private function listenGetPostponedResponse() {
if(isset($_POST[self::POST_VAR_NAME]['getPostponedResponse'])) {
header('Content-Type: application/json; charset=' . self::CLIENT_ENCODING);
echo $this->getPostponeStorage()->pop($_POST[self::POST_VAR_NAME]['getPostponedResponse']);
$this->disable();
exit;
}
}
}
abstract class DataObject {
public function __construct(array $properties = array()) {
foreach($properties as $property => $value) {
$this->$property = $value;
}
}
}
final class Client extends DataObject {
public $protocol;
/** @var ClientAuth|null */
public $auth;
}
final class ClientAuth extends DataObject {
public $publicKey;
public $token;
}
final class ServerAuthStatus extends DataObject {
public $publicKey;
public $isSuccess;
}
final class Response extends DataObject {
public $protocol = Connector::SERVER_PROTOCOL;
/** @var ServerAuthStatus */
public $auth;
public $docRoot;
public $sourcesBasePath;
public $getBackData;
public $isLocal;
public $isSslOnlyMode;
public $isEvalEnabled;
public $messages = array();
}
final class PostponedResponse extends DataObject {
public $protocol = Connector::SERVER_PROTOCOL;
public $isPostponed = true;
public $id;
}
abstract class Message extends DataObject {
public $type;
}
abstract class EventMessage extends Message {
public $data;
public $file;
public $line;
/** @var null|TraceCall[] */
public $trace;
}
final class TraceCall extends DataObject {
public $file;
public $line;
public $call;
}
final class DebugMessage extends EventMessage {
public $type = 'debug';
public $tags;
}
final class ErrorMessage extends EventMessage {
public $type = 'error';
public $code;
public $class;
}
final class EvalResultMessage extends Message {
public $type = 'eval_result';
public $return;
public $output;
public $time;
}