├── .gitignore ├── .htaccess ├── LICENSE ├── README.md └── no.php /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *swp 3 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | RewriteBase / 4 | RewriteRule ^no\.php$ - [L] 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteCond %{REQUEST_FILENAME} !-d 7 | RewriteRule . /no.php [L] 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Franzl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # no.php 2 | 3 | Transparent reverse proxy written in PHP that allows you to not have to write PHP any more. 4 | 5 | This short, single-file, 130-line PHP script is a simple and fully transparent HTTP(S) reverse proxy written in PHP that allows you to never have to use PHP again for a new project, if you feel so inclined, for example if you are forced to host on a fully 3rd-party-managed server where you can't do more than run PHP and upload files via FTP. The PHP script simply reads all requests from a browser pointed to it, forwards them (via PHP's curl library) to a web application listening at another URL (e.g. on a more powerful, more secure, more private, or more capable server in a different data center), and returns the responses transparently and unmodified. 6 | 7 | Supports: 8 | 9 | * Regular and XMLHttpRequests (AJAX) 10 | * All HTTP headers without discrimination 11 | * GET and POST verbs 12 | * Content types (HTTP payload) without discrimination 13 | * Redirects (internal redirects are rewritten to relative URIs) 14 | * Multipart content type 15 | * Cookies (with conversion of the backend domain to the no.php host) 16 | 17 | Does not support (or not tested): 18 | 19 | * HTTP verbs other than GET and POST (but these are usually emulated anyway) 20 | * HTTP greater than version 1.1 (e.g. reusable connections) 21 | * Upgrade to websocket (persistent connections) 22 | 23 | 24 | ## Usage illustrated by the standard example 25 | 26 | You have a non-PHP web application (called the "backend") listening on `https://myapp.backend.com:3000` but due to constraints you must make it available on a shared hosting server called `https://example.com/subdir` which only supports PHP and can't be configured at all. On latter server, Apache (or Nginx, doesn't matter) will usually do the following: 27 | 28 | 1. If a URI points to a .php file, this file will be interpreted 29 | 2. If a URI points to a file that is not existing, a 404 status will be returned. 30 | 31 | Using no.php, to accomodate the second case, all URIs of the proxied web app (including static files) must be appended to the URI `https://example.com/subdir/no.php`. For example: 32 | 33 | https://example.com/subdir/no.php/images/image.png 34 | https://example.com/subdir/no.php/people/15/edit 35 | 36 | If your backend app supports that extra `/subdir/no.php` prefix to all paths, you are all set and ready to use no.php. Then: 37 | 38 | 1. Simply copy `no.php` into the `subdir` directory of example.com 39 | 2. Change `$backend_url` in `no.php` to `"https://myapp.backend.com:3000"` 40 | 3. Point a browser to `https://example.com/subdir/no.php` 41 | 42 | 43 | In Ruby on Rails for example you must do a minimal adaptation to facilitate the mentioned URL prefix -- please consult the Ruby on Rails documentation for full details, but here is a hint: 44 | 45 | ENV['RAILS_RELATIVE_URL_ROOT'] = "/subdir/no.php" 46 | 47 | Rails.application.configure do 48 | config.relative_url_root = ENV['RAILS_RELATIVE_URL_ROOT'] 49 | end 50 | 51 | Rails.application.routes.draw do 52 | scope path: ENV['RAILS_RELATIVE_URL_ROOT'] do 53 | # routes here 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /no.php: -------------------------------------------------------------------------------- 1 | $value) { 49 | if(preg_match("/^HTTP/", $key)) { # only keep HTTP headers 50 | if(preg_match("/^HTTP_HOST/", $key) == 0 && # let curl set the actual host/proxy 51 | preg_match("/^HTTP_ORIGIN/", $key) == 0 && 52 | preg_match("/^HTTP_CONTENT_LEN/", $key) == 0 && # let curl set the actual content length 53 | preg_match("/^HTTPS/", $key) == 0 54 | ) { 55 | $key = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); 56 | if ($key) 57 | array_push($headers, "$key: $value"); 58 | } 59 | } elseif (preg_match("/^CONTENT_TYPE/", $key)) { 60 | 61 | $key = "Content-Type"; 62 | 63 | if(preg_match("/^multipart/", strtolower($value)) && $multipart_delimiter) { 64 | $value = "multipart/form-data; boundary=" . $multipart_delimiter; 65 | array_push($headers, "$key: $value"); 66 | } 67 | else if(preg_match("/^application\/json/", strtolower($value))) { 68 | // Handle application/json 69 | array_push($headers, "$key: $value"); 70 | } 71 | } 72 | } 73 | return $headers; 74 | } 75 | 76 | function build_domain_regex($hostname) 77 | { 78 | $names = explode('.', $hostname); //assumes main domain is the TLD 79 | $regex = ""; 80 | for ($i= 0; $i < count ($names)-2; $i++) 81 | { 82 | $regex .= '['.$names[$i].'.]?'; 83 | } 84 | $main_domain = $names[count($names)-2] .".". $names[count($names)-1]; 85 | $regex .= $main_domain; 86 | return $regex; 87 | } 88 | 89 | function build_multipart_data_files($delimiter, $fields, $files) { 90 | # Inspiration from: https://gist.github.com/maxivak/18fcac476a2f4ea02e5f80b303811d5f :) 91 | $data = ''; 92 | $eol = "\r\n"; 93 | 94 | foreach ($fields as $name => $content) { 95 | $data .= "--" . $delimiter . $eol 96 | . 'Content-Disposition: form-data; name="' . $name . "\"".$eol.$eol 97 | . $content . $eol; 98 | } 99 | 100 | foreach ($files as $name => $content) { 101 | $data .= "--" . $delimiter . $eol 102 | . 'Content-Disposition: form-data; name="' . $name . '"; filename="' . $name . '"' . $eol 103 | . 'Content-Transfer-Encoding: binary'.$eol 104 | ; 105 | $data .= $eol; 106 | $data .= $content . $eol; 107 | } 108 | $data .= "--" . $delimiter . "--".$eol; 109 | 110 | return $data; 111 | } 112 | 113 | $curl = curl_init( $url ); 114 | curl_setopt( $curl, CURLOPT_HTTPHEADER, getRequestHeaders() ); 115 | curl_setopt( $curl, CURLOPT_FOLLOWLOCATION, true ); # follow redirects 116 | curl_setopt( $curl, CURLOPT_HEADER, true ); # include the headers in the output 117 | curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); # return output as string 118 | 119 | if ( strtolower($_SERVER['REQUEST_METHOD']) == 'post' ) { 120 | curl_setopt( $curl, CURLOPT_POST, true ); 121 | $post_data = file_get_contents("php://input"); 122 | 123 | if (preg_match("/^multipart/", strtolower($_SERVER['CONTENT_TYPE']))) { 124 | $delimiter = '-------------' . uniqid(); 125 | $post_data = build_multipart_data_files($delimiter, $_POST, $_FILES); 126 | curl_setopt( $curl, CURLOPT_HTTPHEADER, getRequestHeaders($delimiter) ); 127 | } 128 | 129 | curl_setopt( $curl, CURLOPT_POSTFIELDS, $post_data ); 130 | } 131 | 132 | $contents = curl_exec( $curl ); # reverse proxy. the actual request to the backend server. 133 | curl_close( $curl ); # curl is done now 134 | 135 | 136 | $contents = preg_replace('/^HTTP\/1.1 3.*(?=HTTP\/1\.1)/sm', '', $contents); # remove redirection headers 137 | list( $header_text, $contents ) = preg_split( '/([\r\n][\r\n])\\1/', $contents, 2 ); 138 | 139 | $headers_arr = preg_split( '/[\r\n]+/', $header_text ); 140 | 141 | // Propagate headers to response. 142 | foreach ( $headers_arr as $header ) { 143 | if ( !preg_match( '/^Transfer-Encoding:/i', $header ) ) { 144 | if ( preg_match( '/^Location:/i', $header ) ) { 145 | # rewrite absolute local redirects to relative ones 146 | $header = str_replace($backend_url, "/", $header); 147 | } 148 | else if ( preg_match( '/^set-cookie:/i', $header ) ) { 149 | # replace original domain name in Set-Cookie headers with our server's domain 150 | $domain_regex = build_domain_regex($backend_info['host']); 151 | $header = preg_replace('/Domain='.$domain_regex.'/', 'Domain='.$host, $header); 152 | } 153 | header( $header, false ); 154 | } 155 | } 156 | 157 | print $contents; # return the proxied request result to the browser 158 | 159 | ?> 160 | --------------------------------------------------------------------------------