├── LICENSE ├── README.md └── index.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2021, Andreas Hackl 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Single .php File Host 2 | Minimalistic file host in a single PHP script. 3 | 4 | `curl`-able like any proper file host and pastebin ought to be. 5 | i.e. you can upload a file via `curl -F "file=@/path/to/your/file.jpg" https://example.com/` 6 | 7 | Uploaded files get randomised names but keep their extensions. That means serving them is easily outsourced to the web server and is not handled by this script. 8 | 9 | There's also a mechanism for removing files over a certain age, which can be invoked by calling the script with a command line argument. 10 | 11 | # Config 12 | All configuration is done using the global variables at the top of **index.php**. Hopefully they're explained well enough in the short comments besides them. 13 | 14 | To accommodate for larger uploads, you'll also need to set the following values in your php.ini: 15 | upload_max_filesize 16 | post_max_size 17 | max_input_time 18 | max_execution_time 19 | (The output of index.php will also warn you, if any of those are set too small) 20 | 21 | The code responsible for the default info text can be found at the very bottom of index.php, in case you want to reword anything. 22 | 23 | ## Apache 24 | 25 | ``` 26 | 27 | Options +FollowSymLinks -MultiViews -Indexes 28 | AddDefaultCharset UTF-8 29 | AllowOverride None 30 | 31 | RewriteEngine On 32 | RewriteCond "%{ENV:REDIRECT_STATUS}" "^$" 33 | RewriteRule "^/?$" "index.php" [L,END] 34 | RewriteRule "^(.+)$" "files/$1" [L,END] 35 | 36 | 37 | 38 | Options -ExecCGI 39 | php_flag engine off 40 | SetHandler None 41 | AddType text/plain .php .php5 .html .htm .cpp .c .h .sh 42 | 43 | ``` 44 | 45 | ## Nginx 46 | ``` 47 | root /path/to/webroot; 48 | index index.php; 49 | 50 | location ~ /(.+)$ { 51 | root /path/to/webroot/files; 52 | } 53 | 54 | location = / { 55 | include fastcgi_params; 56 | fastcgi_param HTTP_PROXY ""; 57 | fastcgi_intercept_errors On; 58 | fastcgi_param SCRIPT_NAME index.php; 59 | fastcgi_param SCRIPT_FILENAME /path/to/webroot/index.php; 60 | fastcgi_param QUERY_STRING $query_string; 61 | fastcgi_pass 127.0.0.1:9000; 62 | } 63 | ``` 64 | 65 | # Purging Old Files 66 | To check for any files that exceed their max age and delete them, you need to call index.php with the argument "purge" 67 | ```bash 68 | php index.php purge 69 | ``` 70 | 71 | To automate this, simply create a cron job: 72 | ``` 73 | 0 0 * * * cd /path/to/the/root; php index.php purge > /dev/null 74 | ``` 75 | If you specify **$STORE_PATH** using an absolute path, you can omit the **cd** 76 | 77 | 78 | ## Max. File Age 79 | The max age of a file is computed using the following formula: 80 | ``` 81 | $file_max_age = $MIN_FILEAGE + 82 | ($MAX_FILEAGE - $MIN_FILEAGE) * 83 | pow(1-($fileSize/$MAX_FILESIZE),$DECAY_EXP); 84 | ``` 85 | ...which is a basic exponential decay curve that favours smaller files, meaning small files are kept longer and really big ones are deleted relatively quickly. 86 | **$DECAY_EXP** is one of the configurable globals and basically makes the curve more or less exponential-looking. Set to 1 for a completely linear relationship. 87 | 88 | # Related Things 89 | - [ssh2p](https://github.com/Rouji/ssh2p) and [nc2p](https://github.com/Rouji/nc2p) for adding the ability to upload via `ssh` and `netcat`. 90 | - [Docker container](https://github.com/Rouji/single_php_filehost_docker) 91 | 92 | # FAQ 93 | **Q:** Can you add this or that feature? 94 | **A:** This is mostly just a snapshot of what I'm doing on [x0.at](https://x0.at/). But I'm open to suggestions and PRs, as long as they do something useful that can't be done outside of the script itself (e.g. auth could be done in a .htaccess, malware scanning can be done in the `EXTERNAL_HOOK`, ...) and they don't go against the KISS principle. 95 | 96 | **Q:** Why is the index page so ugly? (And PRs regarding styling) 97 | **A:** To some degree because of KISS, but also because I'm not trying to make the next super flashy, super popular Megaupload clone. This is more aimed at a minority of nerds with command line fetishes. 98 | 99 | **Q:** OMG hosting this without user accounts or logins is so dangerous! Change that now!!1 100 | **A:** I've been running x0.at for *years* now and like to think I know what I'm doing. I'll maybe consider changing how I run it, should it become a problem. *But* I also don't see that as a concern to be dealt with inside this script. If you want to run your copy of this with logins, use basic auth on top of it or something. 101 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | Warning: php.ini: $ini_name ($ini_val) set lower than $var_name ($var_val)\n"); 57 | }; 58 | 59 | $warn_config_value('upload_max_filesize', 'MAX_FILESIZE', CONFIG::MAX_FILESIZE); 60 | $warn_config_value('post_max_size', 'MAX_FILESIZE', CONFIG::MAX_FILESIZE); 61 | $warn_config_value('max_input_time', 'UPLOAD_TIMEOUT', CONFIG::UPLOAD_TIMEOUT); 62 | $warn_config_value('max_execution_time', 'UPLOAD_TIMEOUT', CONFIG::UPLOAD_TIMEOUT); 63 | } 64 | 65 | //extract extension from a path (does not include the dot) 66 | function ext_by_path(string $path) : string 67 | { 68 | $ext = pathinfo($path, PATHINFO_EXTENSION); 69 | //special handling of .tar.* archives 70 | $ext2 = pathinfo(substr($path,0,-(strlen($ext)+1)), PATHINFO_EXTENSION); 71 | if ($ext2 === 'tar') 72 | { 73 | $ext = $ext2.'.'.$ext; 74 | } 75 | return $ext; 76 | } 77 | 78 | function ext_by_finfo(string $path) : string 79 | { 80 | $finfo = finfo_open(FILEINFO_EXTENSION); 81 | $finfo_ext = finfo_file($finfo, $path); 82 | finfo_close($finfo); 83 | if ($finfo_ext != '???') 84 | { 85 | return explode('/', $finfo_ext, 2)[0]; 86 | } 87 | else 88 | { 89 | $finfo = finfo_open(); 90 | $finfo_info = finfo_file($finfo, $path); 91 | finfo_close($finfo); 92 | if (strstr($finfo_info, 'text') !== false) 93 | { 94 | return 'txt'; 95 | } 96 | } 97 | return ''; 98 | } 99 | 100 | // store an uploaded file, given its name and temporary path (e.g. values straight out of $_FILES) 101 | // files are stored wit a randomised name, but with their original extension 102 | // 103 | // $name: original filename 104 | // $tmpfile: temporary path of uploaded file 105 | // $formatted: set to true to display formatted message instead of bare link 106 | function store_file(string $name, string $tmpfile, bool $formatted = false) : void 107 | { 108 | //create folder, if it doesn't exist 109 | if (!file_exists(CONFIG::STORE_PATH)) 110 | { 111 | mkdir(CONFIG::STORE_PATH, 0750, true); //TODO: error handling 112 | } 113 | 114 | //check file size 115 | $size = filesize($tmpfile); 116 | if ($size > CONFIG::MAX_FILESIZE * 1024 * 1024) 117 | { 118 | header('HTTP/1.0 413 Payload Too Large'); 119 | print("Error 413: Max File Size ({CONFIG::MAX_FILESIZE} MiB) Exceeded\n"); 120 | return; 121 | } 122 | if ($size == 0) 123 | { 124 | header('HTTP/1.0 400 Bad Request'); 125 | print('Error 400: Uploaded file is empty\n'); 126 | return; 127 | } 128 | 129 | $ext = ext_by_path($name); 130 | if (empty($ext) && CONFIG::AUTO_FILE_EXT) 131 | { 132 | $ext = ext_by_finfo($tmpfile); 133 | } 134 | $ext = substr($ext, 0, CONFIG::MAX_EXT_LEN); 135 | $tries_per_len=3; //try random names a few times before upping the length 136 | 137 | $id_length=CONFIG::MIN_ID_LENGTH; 138 | if(isset($_POST['id_length']) && ctype_digit($_POST['id_length'])) { 139 | $id_length = max(CONFIG::MIN_ID_LENGTH, min(CONFIG::MAX_ID_LENGTH, $_POST['id_length'])); 140 | } 141 | 142 | for ($len = $id_length; ; ++$len) 143 | { 144 | for ($n=0; $n<=$tries_per_len; ++$n) 145 | { 146 | $id = rnd_str($len); 147 | $basename = $id . (empty($ext) ? '' : '.' . $ext); 148 | $target_file = CONFIG::STORE_PATH . $basename; 149 | 150 | if (!file_exists($target_file)) 151 | break 2; 152 | } 153 | } 154 | 155 | $res = move_uploaded_file($tmpfile, $target_file); 156 | if (!$res) 157 | { 158 | //TODO: proper error handling? 159 | header('HTTP/1.0 520 Unknown Error'); 160 | return; 161 | } 162 | 163 | if (CONFIG::EXTERNAL_HOOK !== null) 164 | { 165 | putenv('REMOTE_ADDR='.$_SERVER['REMOTE_ADDR']); 166 | putenv('ORIGINAL_NAME='.$name); 167 | putenv('STORED_FILE='.$target_file); 168 | $ret = -1; 169 | $out = null; 170 | $last_line = exec(CONFIG::EXTERNAL_HOOK, $out, $ret); 171 | if ($last_line !== false && $ret !== 0) 172 | { 173 | unlink($target_file); 174 | header('HTTP/1.0 400 Bad Request'); 175 | print("Error: $last_line\n"); 176 | return; 177 | } 178 | } 179 | 180 | //print the download link of the file 181 | $url = sprintf(CONFIG::SITE_URL().'/'.CONFIG::DOWNLOAD_PATH, $basename); 182 | 183 | if ($formatted) 184 | { 185 | print("
Access your file here: $url
"); 186 | } 187 | else 188 | { 189 | print("$url\n"); 190 | } 191 | 192 | // log uploader's IP, original filename, etc. 193 | if (CONFIG::LOG_PATH) 194 | { 195 | file_put_contents( 196 | CONFIG::LOG_PATH, 197 | implode("\t", array( 198 | date('c'), 199 | $_SERVER['REMOTE_ADDR'], 200 | filesize($tmpfile), 201 | escapeshellarg($name), 202 | $basename 203 | )) . "\n", 204 | FILE_APPEND 205 | ); 206 | } 207 | } 208 | 209 | // purge all files older than their retention period allows. 210 | function purge_files() : void 211 | { 212 | $num_del = 0; //number of deleted files 213 | $total_size = 0; //total size of deleted files 214 | 215 | //for each stored file 216 | foreach (scandir(CONFIG::STORE_PATH) as $file) 217 | { 218 | //skip virtual . and .. files 219 | if ($file === '.' || 220 | $file === '..') 221 | { 222 | continue; 223 | } 224 | 225 | $file = CONFIG::STORE_PATH . $file; 226 | 227 | $file_size = filesize($file) / (1024*1024); //size in MiB 228 | $file_age = (time()-filemtime($file)) / (60*60*24); //age in days 229 | 230 | //keep all files below the min age 231 | if ($file_age < CONFIG::MIN_FILEAGE) 232 | { 233 | continue; 234 | } 235 | 236 | //calculate the maximum age in days for this file 237 | $file_max_age = CONFIG::MIN_FILEAGE + 238 | (CONFIG::MAX_FILEAGE - CONFIG::MIN_FILEAGE) * 239 | pow(1 - ($file_size / CONFIG::MAX_FILESIZE), CONFIG::DECAY_EXP); 240 | 241 | //delete if older 242 | if ($file_age > $file_max_age) 243 | { 244 | unlink($file); 245 | 246 | print("deleted $file, $file_size MiB, $file_age days old\n"); 247 | $num_del += 1; 248 | $total_size += $file_size; 249 | } 250 | } 251 | print("Deleted $num_del files totalling $total_size MiB\n"); 252 | } 253 | 254 | function send_text_file(string $filename, string $content) : void 255 | { 256 | header('Content-type: application/octet-stream'); 257 | header("Content-Disposition: attachment; filename=\"$filename\""); 258 | header('Content-Length: '.strlen($content)); 259 | print($content); 260 | } 261 | 262 | // send a ShareX custom uploader config as .json 263 | function send_sharex_config() : void 264 | { 265 | $name = $_SERVER['SERVER_NAME']; 266 | $site_url = str_replace("?sharex", "", CONFIG::SCRIPT_URL()); 267 | send_text_file($name.'.sxcu', << 316 | 317 | 318 | Filehost 319 | 320 | 321 | 322 | 323 |
324 |  === How To Upload ===
325 | You can upload files to this site via a simple HTTP POST, e.g. using curl:
326 | curl -F "file=@/path/to/your/file.jpg" $site_url
327 | 
328 | Or if you want to pipe to curl *and* have a file extension, add a "filename":
329 | echo "hello" | curl -F "file=@-;filename=.txt" $site_url
330 | $length_info
331 | On Windows, you can use ShareX and import this custom uploader.
332 | On Android, you can use an app called Hupl with this uploader.
333 | 
334 | 
335 | Or simply choose a file and click "Upload" below:
336 | (Hint: If you're lucky, your browser may support drag-and-drop onto the file 
337 | selection input.)
338 | 
339 |
340 | 341 | 342 | 343 |
344 |
345 | 
346 | 
347 |  === File Sizes etc. ===
348 | The maximum allowed file size is $max_size MiB.
349 | 
350 | Files are kept for a minimum of $min_age, and a maximum of $max_age Days.
351 | 
352 | How long a file is kept depends on its size. Larger files are deleted earlier 
353 | than small ones. This relation is non-linear and skewed in favour of small 
354 | files.
355 | 
356 | The exact formula for determining the maximum age for a file is:
357 | 
358 | MIN_AGE + (MAX_AGE - MIN_AGE) * (1-(FILE_SIZE/MAX_SIZE))^$decay
359 | 
360 | 
361 |  === Source ===
362 | The PHP script used to provide this service is open source and available on 
363 | GitHub
364 | 
365 | 
366 |  === Contact ===
367 | If you want to report abuse of this service, or have any other inquiries, 
368 | please write an email to $mail
369 | 
370 | 371 | 372 | EOT; 373 | } 374 | 375 | 376 | // decide what to do, based on POST parameters etc. 377 | if (isset($_FILES['file']['name']) && 378 | isset($_FILES['file']['tmp_name']) && 379 | is_uploaded_file($_FILES['file']['tmp_name'])) 380 | { 381 | //file was uploaded, store it 382 | $formatted = isset($_REQUEST['formatted']); 383 | store_file($_FILES['file']['name'], 384 | $_FILES['file']['tmp_name'], 385 | $formatted); 386 | } 387 | else if (isset($_GET['sharex'])) 388 | { 389 | send_sharex_config(); 390 | } 391 | else if (isset($_GET['hupl'])) 392 | { 393 | send_hupl_config(); 394 | } 395 | else if ($argv[1] ?? null === 'purge') 396 | { 397 | purge_files(); 398 | } 399 | else 400 | { 401 | check_config(); 402 | print_index(); 403 | } 404 | --------------------------------------------------------------------------------