├── .gitignore ├── .styleci.yml ├── .travis.yml ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Adapter │ ├── AdapterInterface.php │ ├── Dummy │ │ └── DummyAdapter.php │ └── Guzzle │ │ └── GuzzleAdapter.php ├── Exception │ ├── InvalidArgumentException.php │ └── UnexpectedValueException.php ├── Filter │ ├── FilterInterface.php │ ├── RemoveEncodingFilter.php │ ├── RemoveLocationFilter.php │ └── RewriteLocationFilter.php └── Proxy.php └── tests └── Proxy ├── Adapter ├── Dummy │ └── DummyAdapterTest.php └── Guzzle │ └── GuzzleAdapterTest.php ├── Filter ├── RemoveEncodingFilterTest.php ├── RemoveLocationFilterTest.php └── RewriteLocationFilterTest.php └── ProxyTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /vendor 3 | composer.lock 4 | .DS_Store 5 | index.php 6 | .idea/ 7 | *.ipr 8 | *.iml 9 | *.iws 10 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr2 2 | 3 | enabled: 4 | - duplicate_semicolon 5 | - empty_return 6 | - extra_empty_lines 7 | - operators_spaces 8 | - phpdoc_indent 9 | - remove_leading_slash_use 10 | - spaces_cast 11 | - ternary_spaces 12 | - unused_use 13 | 14 | disabled: 15 | - braces 16 | - parenthesis 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | - 7.3 7 | 8 | sudo: false 9 | 10 | dist: trusty 11 | 12 | before_script: 13 | - composer self-update 14 | - travis_retry composer install --no-interaction --prefer-source 15 | 16 | script: 17 | - mkdir -p build/logs 18 | - vendor/bin/phpunit --coverage-clover build/logs/clover.xml 19 | 20 | after_script: 21 | - vendor/bin/php-coveralls -v 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Proxy 2 | 3 | [![Build Status](http://img.shields.io/travis/jenssegers/php-proxy.svg)](https://travis-ci.org/jenssegers/php-proxy) [![Coverage Status](http://img.shields.io/coveralls/jenssegers/php-proxy.svg)](https://coveralls.io/r/jenssegers/php-proxy?branch=master) 4 | 5 | This is a HTTP/HTTPS proxy script that forwards requests to a different server and returns the response. The Proxy class uses PSR7 request/response objects as input/output, and uses Guzzle to do the actual HTTP request. 6 | 7 | ## Installation 8 | 9 | Install using composer: 10 | 11 | ``` 12 | composer require jenssegers/proxy 13 | ``` 14 | 15 | ## Example 16 | 17 | The following example creates a request object, based on the current browser request, and forwards it to `example.com`. The `RemoveEncodingFilter` removes the encoding headers from the original response so that the current webserver can set these correctly. 18 | 19 | ```php 20 | use Proxy\Proxy; 21 | use Proxy\Adapter\Guzzle\GuzzleAdapter; 22 | use Proxy\Filter\RemoveEncodingFilter; 23 | use Laminas\Diactoros\ServerRequestFactory; 24 | 25 | // Create a PSR7 request based on the current browser request. 26 | $request = ServerRequestFactory::fromGlobals(); 27 | 28 | // Create a guzzle client 29 | $guzzle = new GuzzleHttp\Client(); 30 | 31 | // Create the proxy instance 32 | $proxy = new Proxy(new GuzzleAdapter($guzzle)); 33 | 34 | // Add a response filter that removes the encoding headers. 35 | $proxy->filter(new RemoveEncodingFilter()); 36 | 37 | try { 38 | // Forward the request and get the response. 39 | $response = $proxy->forward($request)->to('http://example.com'); 40 | 41 | // Output response to the browser. 42 | (new Laminas\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response); 43 | } catch(\GuzzleHttp\Exception\BadResponseException $e) { 44 | // Correct way to handle bad responses 45 | (new Laminas\HttpHandlerRunner\Emitter\SapiEmitter)->emit($e->getResponse()); 46 | } 47 | ``` 48 | 49 | ## Filters 50 | 51 | You can apply filters to the requests and responses using the middleware strategy: 52 | 53 | ```php 54 | $response = $proxy 55 | ->forward($request) 56 | ->filter(function ($request, $response, $next) { 57 | // Manipulate the request object. 58 | $request = $request->withHeader('User-Agent', 'FishBot/1.0'); 59 | 60 | // Call the next item in the middleware. 61 | $response = $next($request, $response); 62 | 63 | // Manipulate the response object. 64 | $response = $response->withHeader('X-Proxy-Foo', 'Bar'); 65 | 66 | return $response; 67 | }) 68 | ->to('http://example.com'); 69 | ``` 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jenssegers/proxy", 3 | "description": "Proxy library that forwards requests to the desired url and returns the response.", 4 | "keywords": ["proxy"], 5 | "homepage": "https://github.com/jenssegers/php-proxy", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Jens Segers", 10 | "homepage": "https://jenssegers.com" 11 | }, 12 | { 13 | "name": "Ota Mares", 14 | "email": "o.mares@rebuy.de", 15 | "homepage": "http://www.rebuy.de" 16 | } 17 | ], 18 | "require": { 19 | "php": "^5.6 || ^7.0", 20 | "psr/http-message": "^1.0", 21 | "guzzlehttp/guzzle": "^6.0", 22 | "laminas/laminas-diactoros": "^2.0", 23 | "relay/relay": "^1.0", 24 | "laminas/laminas-httphandlerrunner": "^1.1" 25 | }, 26 | "require-dev": { 27 | "phpunit/phpunit": "^5.0|^6.0|^7.0", 28 | "php-coveralls/php-coveralls": "^2.0", 29 | "mockery/mockery": "^1.1" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Proxy\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Proxy\\": "tests" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests/Proxy/ 15 | 16 | 17 | 18 | 19 | ./src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Adapter/AdapterInterface.php: -------------------------------------------------------------------------------- 1 | getBody(), 200); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Adapter/Guzzle/GuzzleAdapter.php: -------------------------------------------------------------------------------- 1 | client = $client ?: new Client; 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function send(RequestInterface $request) 30 | { 31 | return $this->client->send($request); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | withoutHeader(self::TRANSFER_ENCODING) 22 | ->withoutHeader(self::CONTENT_ENCODING); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Filter/RemoveLocationFilter.php: -------------------------------------------------------------------------------- 1 | hasHeader(self::LOCATION)) { 20 | $response = $response 21 | ->withHeader('X-Proxy-Location', $response->getHeader(self::LOCATION)) 22 | ->withoutHeader(self::LOCATION); 23 | } 24 | 25 | return $response; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Filter/RewriteLocationFilter.php: -------------------------------------------------------------------------------- 1 | hasHeader(self::LOCATION)) { 20 | $location = $response->getHeader(self::LOCATION)[0]; 21 | $original = parse_url($location); 22 | 23 | $target = rtrim(str_replace(basename($_SERVER['SCRIPT_NAME']), '', $_SERVER['SCRIPT_NAME']), '/'); 24 | 25 | if (isset($original['path'])) { 26 | $target .= $original['path']; 27 | } 28 | 29 | if (isset($original['query'])) { 30 | $target .= '?' . $original['query']; 31 | } 32 | 33 | $response = $response 34 | ->withHeader('X-Proxy-Location', $location) 35 | ->withHeader(self::LOCATION, $target); 36 | } 37 | return $response; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Proxy.php: -------------------------------------------------------------------------------- 1 | adapter = $adapter; 37 | } 38 | 39 | /** 40 | * Prepare the proxy to forward a request instance. 41 | * 42 | * @param RequestInterface $request 43 | * @return $this 44 | */ 45 | public function forward(RequestInterface $request) 46 | { 47 | $this->request = $request; 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * Forward the request to the target url and return the response. 54 | * 55 | * @param string $target 56 | * @throws UnexpectedValueException 57 | * @return ResponseInterface 58 | */ 59 | public function to($target) 60 | { 61 | if ($this->request === null) { 62 | throw new UnexpectedValueException('Missing request instance.'); 63 | } 64 | 65 | $target = new Uri($target); 66 | 67 | // Overwrite target scheme, host and port. 68 | $uri = $this->request->getUri() 69 | ->withScheme($target->getScheme()) 70 | ->withHost($target->getHost()) 71 | ->withPort($target->getPort()); 72 | 73 | // Check for subdirectory. 74 | if ($path = $target->getPath()) { 75 | $uri = $uri->withPath(rtrim($path, '/') . '/' . ltrim($uri->getPath(), '/')); 76 | } 77 | 78 | $request = $this->request->withUri($uri); 79 | 80 | $stack = $this->filters; 81 | 82 | $stack[] = function (RequestInterface $request, ResponseInterface $response, callable $next) { 83 | try { 84 | $response = $this->adapter->send($request); 85 | } catch (ClientException $ex) { 86 | $response = $ex->getResponse(); 87 | } 88 | 89 | return $next($request, $response); 90 | }; 91 | 92 | $relay = (new RelayBuilder)->newInstance($stack); 93 | 94 | return $relay($request, new Response); 95 | } 96 | 97 | /** 98 | * Add a filter middleware. 99 | * 100 | * @param callable $callable 101 | * @return $this 102 | */ 103 | public function filter(callable $callable) 104 | { 105 | $this->filters[] = $callable; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * @return RequestInterface 112 | */ 113 | public function getRequest() 114 | { 115 | return $this->request; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Proxy/Adapter/Dummy/DummyAdapterTest.php: -------------------------------------------------------------------------------- 1 | adapter = new DummyAdapter(); 19 | } 20 | 21 | /** 22 | * @test 23 | */ 24 | public function adapter_returns_psr_response() 25 | { 26 | $response = $this->adapter->send(ServerRequestFactory::fromGlobals(), '/'); 27 | 28 | $this->assertInstanceOf(ResponseInterface::class, $response); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Proxy/Adapter/Guzzle/GuzzleAdapterTest.php: -------------------------------------------------------------------------------- 1 | 'Mock']; 24 | 25 | /** 26 | * @var int 27 | */ 28 | private $status = 200; 29 | 30 | /** 31 | * @var string 32 | */ 33 | private $body = 'Totally awesome response body'; 34 | 35 | public function setUp() 36 | { 37 | $mock = new MockHandler([ 38 | $this->createResponse(), 39 | ]); 40 | 41 | $client = new Client(['handler' => $mock]); 42 | 43 | $this->adapter = new GuzzleAdapter($client); 44 | } 45 | 46 | /** 47 | * @test 48 | */ 49 | public function adapter_returns_psr_response() 50 | { 51 | $response = $this->sendRequest(); 52 | 53 | $this->assertInstanceOf(ResponseInterface::class, $response); 54 | } 55 | 56 | /** 57 | * @test 58 | */ 59 | public function response_contains_body() 60 | { 61 | $response = $this->sendRequest(); 62 | 63 | $this->assertEquals($this->body, $response->getBody()); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function response_contains_statuscode() 70 | { 71 | $response = $this->sendRequest(); 72 | 73 | $this->assertEquals($this->status, $response->getStatusCode()); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function response_contains_header() 80 | { 81 | $response = $this->sendRequest(); 82 | 83 | $this->assertEquals('Mock', $response->getHeader('Server')[0]); 84 | } 85 | 86 | /** 87 | * @test 88 | */ 89 | public function adapter_sends_request() 90 | { 91 | $request = new Request('http://localhost', 'GET'); 92 | 93 | $clientMock = $this->getMockBuilder(Client::class) 94 | ->disableOriginalConstructor() 95 | ->getMock(); 96 | 97 | $clientMock->expects($this->once()) 98 | ->method('send') 99 | ->with($request) 100 | ->willReturn($this->createResponse()); 101 | 102 | $adapter = new GuzzleAdapter($clientMock); 103 | 104 | $adapter->send($request); 105 | } 106 | 107 | /** 108 | * @return ResponseInterface 109 | */ 110 | private function sendRequest() 111 | { 112 | $request = new Request('http://localhost', 'GET'); 113 | 114 | return $this->adapter->send($request); 115 | } 116 | 117 | /** 118 | * @return ResponseInterface 119 | */ 120 | private function createResponse() 121 | { 122 | return new GuzzleResponse($this->status, $this->headers, $this->body); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/Proxy/Filter/RemoveEncodingFilterTest.php: -------------------------------------------------------------------------------- 1 | filter = new RemoveEncodingFilter(); 19 | } 20 | 21 | /** 22 | * @test 23 | */ 24 | public function filter_removes_transfer_encoding() 25 | { 26 | $request = new Request(); 27 | $response = new Response('php://memory', 200, [RemoveEncodingFilter::TRANSFER_ENCODING => 'foo']); 28 | $next = function () use ($response) { 29 | return $response; 30 | }; 31 | 32 | $response = call_user_func($this->filter, $request, $response, $next); 33 | 34 | $this->assertFalse($response->hasHeader(RemoveEncodingFilter::TRANSFER_ENCODING)); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function filter_removes_content_encoding() 41 | { 42 | $request = new Request(); 43 | $response = new Response('php://memory', 200, [RemoveEncodingFilter::TRANSFER_ENCODING => 'foo']); 44 | $next = function ($request, $response) { 45 | return $response; 46 | }; 47 | 48 | $response = call_user_func($this->filter, $request, $response, $next); 49 | 50 | $this->assertFalse($response->hasHeader(RemoveEncodingFilter::CONTENT_ENCODING)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Proxy/Filter/RemoveLocationFilterTest.php: -------------------------------------------------------------------------------- 1 | filter = new RemoveLocationFilter(); 19 | } 20 | 21 | /** 22 | * @test 23 | */ 24 | public function filter_removes_location() 25 | { 26 | $request = new Request(); 27 | $response = new Response('php://memory', 200, [RemoveLocationFilter::LOCATION => 'http://www.example.com']); 28 | $next = function () use ($response) { 29 | return $response; 30 | }; 31 | 32 | $response = call_user_func($this->filter, $request, $response, $next); 33 | 34 | $this->assertFalse($response->hasHeader(RemoveLocationFilter::LOCATION)); 35 | } 36 | 37 | /** 38 | * @test 39 | */ 40 | public function filter_adds_location_as_xheader() 41 | { 42 | $request = new Request(); 43 | $response = new Response('php://memory', 200, [RemoveLocationFilter::LOCATION => 'http://www.example.com']); 44 | $next = function () use ($response) { 45 | return $response; 46 | }; 47 | 48 | $response = call_user_func($this->filter, $request, $response, $next); 49 | 50 | $this->assertEquals('http://www.example.com', $response->getHeader('X-Proxy-Location')[0]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Proxy/Filter/RewriteLocationFilterTest.php: -------------------------------------------------------------------------------- 1 | filter = new RewriteLocationFilter(); 17 | } 18 | 19 | /** 20 | * @test 21 | */ 22 | public function filter_rewrites_location() 23 | { 24 | $_SERVER['SCRIPT_NAME'] = ""; 25 | $redirect_url = 'http://www.example.com/path?arg1=123&arg2=456'; 26 | 27 | $request = new Request(); 28 | $response = new Response('php://memory', 200, [RewriteLocationFilter::LOCATION => $redirect_url]); 29 | $next = function () use ($response) { 30 | return $response; 31 | }; 32 | 33 | $response = call_user_func($this->filter, $request, $response, $next); 34 | 35 | $this->assertTrue($response->hasHeader('X-Proxy-Location')); 36 | $this->assertTrue($response->hasHeader(RewriteLocationFilter::LOCATION)); 37 | $this->assertEquals('/path?arg1=123&arg2=456', $response->getHeaderLine(RewriteLocationFilter::LOCATION)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Proxy/ProxyTest.php: -------------------------------------------------------------------------------- 1 | proxy = new Proxy(new DummyAdapter()); 23 | } 24 | 25 | /** 26 | * @test 27 | * @expectedException UnexpectedValueException 28 | */ 29 | public function to_throws_exception_if_no_request_is_given() 30 | { 31 | $this->proxy->to('http://www.example.com'); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function to_returns_psr_response() 38 | { 39 | $response = $this->proxy->forward(ServerRequestFactory::fromGlobals())->to('http://www.example.com'); 40 | 41 | $this->assertInstanceOf('Psr\Http\Message\ResponseInterface', $response); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function to_applies_filters() 48 | { 49 | $applied = false; 50 | 51 | $this->proxy->forward(ServerRequestFactory::fromGlobals())->filter(function ($request, $response) use (&$applied 52 | ) { 53 | $applied = true; 54 | })->to('http://www.example.com'); 55 | 56 | $this->assertTrue($applied); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | public function to_sends_request() 63 | { 64 | $request = new Request('http://localhost/path?query=yes', 'GET'); 65 | $url = 'https://www.example.com'; 66 | 67 | $adapter = $this->getMockBuilder(DummyAdapter::class) 68 | ->getMock(); 69 | 70 | $verifyParam = $this->callback(function (RequestInterface $request) use ($url) { 71 | return $request->getUri() == 'https://www.example.com/path?query=yes'; 72 | }); 73 | 74 | $adapter->expects($this->once()) 75 | ->method('send') 76 | ->with($verifyParam) 77 | ->willReturn(new Response); 78 | 79 | $proxy = new Proxy($adapter); 80 | $proxy->forward($request)->to($url); 81 | } 82 | 83 | /** 84 | * @test 85 | */ 86 | public function to_sends_request_with_port() 87 | { 88 | $request = new Request('http://localhost/path?query=yes', 'GET'); 89 | $url = 'https://www.example.com:3000'; 90 | 91 | $adapter = $this->getMockBuilder(DummyAdapter::class) 92 | ->getMock(); 93 | 94 | $verifyParam = $this->callback(function (RequestInterface $request) use ($url) { 95 | return $request->getUri() == 'https://www.example.com:3000/path?query=yes'; 96 | }); 97 | 98 | $adapter->expects($this->once()) 99 | ->method('send') 100 | ->with($verifyParam) 101 | ->willReturn(new Response); 102 | 103 | $proxy = new Proxy($adapter); 104 | $proxy->forward($request)->to($url); 105 | } 106 | 107 | /** 108 | * @test 109 | */ 110 | public function to_sends_request_with_subdirectory() 111 | { 112 | $request = new Request('http://localhost/path?query=yes', 'GET'); 113 | $url = 'https://www.example.com/proxy/'; 114 | 115 | $adapter = $this->getMockBuilder(DummyAdapter::class) 116 | ->getMock(); 117 | 118 | $verifyParam = $this->callback(function (RequestInterface $request) use ($url) { 119 | return $request->getUri() == 'https://www.example.com/proxy/path?query=yes'; 120 | }); 121 | 122 | $adapter->expects($this->once()) 123 | ->method('send') 124 | ->with($verifyParam) 125 | ->willReturn(new Response); 126 | 127 | $proxy = new Proxy($adapter); 128 | $proxy->forward($request)->to($url); 129 | } 130 | } 131 | --------------------------------------------------------------------------------