Client
last analyzed

Complexity

Total Complexity 0

Size/Duplication

Total Lines 6
Duplicated Lines 0 %

Coupling/Cohesion

Components 0
Dependencies 1

Test Coverage

Coverage 0%

Importance

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