xref: /dokuwiki/inc/httputils.php (revision 884caed926ca0aa0af6ce3f34ae3aa7317a3361a)
1<?php
2
3/**
4 * Utilities for handling HTTP related tasks
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Andreas Gohr <andi@splitbrain.org>
8 */
9
10define('HTTP_MULTIPART_BOUNDARY', 'D0KuW1K1B0uNDARY');
11define('HTTP_HEADER_LF', "\r\n");
12define('HTTP_CHUNK_SIZE', 16 * 1024);
13
14/**
15 * Checks and sets HTTP headers for conditional HTTP requests
16 *
17 * @param int $timestamp lastmodified time of the cache file
18 * @returns  void or exits with previously header() commands executed
19 * @link     http://simonwillison.net/2003/Apr/23/conditionalGet/
20 *
21 * @author   Simon Willison <swillison@gmail.com>
22 */
23function http_conditionalRequest($timestamp)
24{
25    global $INPUT;
26
27    // A PHP implementation of conditional get, see
28    //   http://fishbowl.pastiche.org/2002/10/21/http_conditional_get_for_rss_hackers/
29    $last_modified = substr(gmdate('r', $timestamp), 0, -5) . 'GMT';
30    $etag = '"' . md5($last_modified) . '"';
31    // Send the headers
32    header("Last-Modified: $last_modified");
33    header("ETag: $etag");
34    // See if the client has provided the required headers
35    $if_modified_since = $INPUT->server->filter('stripslashes')->str('HTTP_IF_MODIFIED_SINCE', false);
36    $if_none_match = $INPUT->server->filter('stripslashes')->str('HTTP_IF_NONE_MATCH', false);
37
38    if (!$if_modified_since && !$if_none_match) {
39        return;
40    }
41
42    // At least one of the headers is there - check them
43    if ($if_none_match && $if_none_match != $etag) {
44        return; // etag is there but doesn't match
45    }
46
47    if ($if_modified_since && $if_modified_since != $last_modified) {
48        return; // if-modified-since is there but doesn't match
49    }
50
51    // Nothing has changed since their last request - serve a 304 and exit
52    header('HTTP/1.0 304 Not Modified');
53
54    // don't produce output, even if compression is on
55    @ob_end_clean();
56    exit;
57}
58
59/**
60 * Let the webserver send the given file via x-sendfile method
61 *
62 * @param string $file absolute path of file to send
63 * @returns  void or exits with previous header() commands executed
64 * @author Chris Smith <chris@jalakai.co.uk>
65 *
66 */
67function http_sendfile($file)
68{
69    global $conf;
70
71    //use x-sendfile header to pass the delivery to compatible web servers
72    if ($conf['xsendfile'] == 1) {
73        header("X-LIGHTTPD-send-file: $file");
74        ob_end_clean();
75        exit;
76    } elseif ($conf['xsendfile'] == 2) {
77        header("X-Sendfile: $file");
78        ob_end_clean();
79        exit;
80    } elseif ($conf['xsendfile'] == 3) {
81        // FS#2388, #2895 nginx needs an internal redirect URL, not a file path.
82        header("X-Accel-Redirect: " . http_xaccel_url($file));
83        ob_end_clean();
84        exit;
85    }
86}
87
88/**
89 * Build the internal redirect URL for nginx's X-Accel-Redirect header
90 *
91 * Construct a request path from a given file path that nginx can resolve to the file through an `internal` location.
92 * A. Files inside the DokuWiki installation are returned as relative paths.
93 * B. Standard data directory paths are returned as /data/* paths regardless of their actual location (reconfigured
94 *    savedir or mediadir).
95 * C. Paths outside the DokuWiki installation are returned with a /_x_accel_redirect/ prefix
96 *
97 * Nginx admins need to configure appropriate internal location mappings.
98 *
99 * @param string $file absolute path of the file to send
100 * @return string the relative URL for the X-Accel-Redirect header
101 */
102function http_xaccel_url($file)
103{
104    global $conf;
105
106    $file = fullpath($file);
107
108    if (str_starts_with($file, DOKU_INC)) {
109        // A. files inside the DokuWiki directory: keep their path relative to the root
110        $path = substr($file, strlen(DOKU_INC));
111    } else {
112        // C. files outside DokuWiki and its data dirs: add prefix (overridden below if a root matches)
113        $path = '_x_accel_redirect/' . ltrim($file, '/');
114
115        // B. files in a relocated data directory: map back to their default URL below data/
116        $roots = [
117            $conf['mediadir']    => 'data/media/',
118            $conf['mediaolddir'] => 'data/media_attic/',
119            $conf['cachedir']    => 'data/cache/',
120            $conf['savedir']     => 'data/',
121        ];
122        // match the most specific (longest) root first so nested directories win
123        uksort($roots, fn($a, $b) => strlen($b) - strlen($a));
124
125        foreach ($roots as $root => $logical) {
126            if (str_starts_with($file, $root . '/')) {
127                $path = $logical . substr($file, strlen($root) + 1);
128                break;
129            }
130        }
131    }
132
133    // URL-encode each segment (nginx URL-decodes the redirect target before resolving it)
134    return DOKU_REL . implode('/', array_map(rawurlencode(...), explode('/', $path)));
135}
136
137/**
138 * Send file contents supporting rangeRequests
139 *
140 * This function exits the running script
141 *
142 * @param resource $fh - file handle for an already open file
143 * @param int $size - size of the whole file
144 * @param int $mime - MIME type of the file
145 *
146 * @author Andreas Gohr <andi@splitbrain.org>
147 */
148function http_rangeRequest($fh, $size, $mime)
149{
150    global $INPUT;
151
152    $ranges = [];
153    $isrange = false;
154
155    header('Accept-Ranges: bytes');
156
157    if (!$INPUT->server->has('HTTP_RANGE')) {
158        // no range requested - send the whole file
159        $ranges[] = [0, $size, $size];
160    } else {
161        $t = explode('=', $INPUT->server->str('HTTP_RANGE'));
162        if (!$t[0] == 'bytes') {
163            // we only understand byte ranges - send the whole file
164            $ranges[] = [0, $size, $size];
165        } else {
166            $isrange = true;
167            // handle multiple ranges
168            $r = explode(',', $t[1]);
169            foreach ($r as $x) {
170                $p = explode('-', $x);
171                $start = (int)$p[0];
172                $end = (int)$p[1];
173                if (!$end) $end = $size - 1;
174                if ($start > $end || $start > $size || $end > $size) {
175                    header('HTTP/1.1 416 Requested Range Not Satisfiable');
176                    echo 'Bad Range Request!';
177                    exit;
178                }
179                $len = $end - $start + 1;
180                $ranges[] = [$start, $end, $len];
181            }
182        }
183    }
184    $parts = count($ranges);
185
186    // now send the type and length headers
187    if (!$isrange) {
188        header("Content-Type: $mime", true);
189    } else {
190        header('HTTP/1.1 206 Partial Content');
191        if ($parts == 1) {
192            header("Content-Type: $mime", true);
193        } else {
194            header('Content-Type: multipart/byteranges; boundary=' . HTTP_MULTIPART_BOUNDARY, true);
195        }
196    }
197
198    // send all ranges
199    for ($i = 0; $i < $parts; $i++) {
200        [$start, $end, $len] = $ranges[$i];
201
202        // multipart or normal headers
203        if ($parts > 1) {
204            echo HTTP_HEADER_LF . '--' . HTTP_MULTIPART_BOUNDARY . HTTP_HEADER_LF;
205            echo "Content-Type: $mime" . HTTP_HEADER_LF;
206            echo "Content-Range: bytes $start-$end/$size" . HTTP_HEADER_LF;
207            echo HTTP_HEADER_LF;
208        } else {
209            header("Content-Length: $len");
210            if ($isrange) {
211                header("Content-Range: bytes $start-$end/$size");
212            }
213        }
214
215        // send file content
216        fseek($fh, $start); //seek to start of range
217        $chunk = ($len > HTTP_CHUNK_SIZE) ? HTTP_CHUNK_SIZE : $len;
218        while (!feof($fh) && $chunk > 0) {
219            @set_time_limit(30); // large files can take a lot of time
220            echo fread($fh, $chunk);
221            flush();
222            $len -= $chunk;
223            $chunk = ($len > HTTP_CHUNK_SIZE) ? HTTP_CHUNK_SIZE : $len;
224        }
225    }
226    if ($parts > 1) {
227        echo HTTP_HEADER_LF . '--' . HTTP_MULTIPART_BOUNDARY . '--' . HTTP_HEADER_LF;
228    }
229
230    // everything should be done here, exit (or return if testing)
231    if (defined('SIMPLE_TEST')) return;
232    exit;
233}
234
235/**
236 * Check for a gzipped version and create if necessary
237 *
238 * return true if there exists a gzip version of the uncompressed file
239 * (samepath/samefilename.sameext.gz) created after the uncompressed file
240 *
241 * @param string $uncompressed_file
242 * @return bool
243 * @author Chris Smith <chris.eureka@jalakai.co.uk>
244 *
245 */
246function http_gzip_valid($uncompressed_file)
247{
248    if (!DOKU_HAS_GZIP) return false;
249
250    $gzip = $uncompressed_file . '.gz';
251    if (filemtime($gzip) < filemtime($uncompressed_file)) {    // filemtime returns false (0) if file doesn't exist
252        return copy($uncompressed_file, 'compress.zlib://' . $gzip);
253    }
254
255    return true;
256}
257
258/**
259 * Set HTTP headers and echo cachefile, if useable
260 *
261 * This function handles output of cacheable resource files. It ses the needed
262 * HTTP headers. If a useable cache is present, it is passed to the web server
263 * and the script is terminated.
264 *
265 * @param string $cache cache file name
266 * @param bool $cache_ok if cache can be used
267 */
268function http_cached($cache, $cache_ok)
269{
270    global $conf;
271
272    // check cache age & handle conditional request
273    // since the resource files are timestamped, we can use a long max age: 1 year
274    header('Cache-Control: public, max-age=31536000');
275    header('Pragma: public');
276    if ($cache_ok) {
277        http_conditionalRequest(filemtime($cache));
278        if ($conf['allowdebug']) header("X-CacheUsed: $cache");
279
280        // finally send output
281        if ($conf['gzip_output'] && http_gzip_valid($cache)) {
282            header('Vary: Accept-Encoding');
283            header('Content-Encoding: gzip');
284            readfile($cache . ".gz");
285        } else {
286            http_sendfile($cache);
287            readfile($cache);
288        }
289        exit;
290    }
291
292    http_conditionalRequest(time());
293}
294
295/**
296 * Cache content and print it
297 *
298 * @param string $file file name
299 * @param string $content
300 */
301function http_cached_finish($file, $content)
302{
303    global $conf;
304
305    // save cache file
306    io_saveFile($file, $content);
307    if (DOKU_HAS_GZIP) io_saveFile("$file.gz", $content);
308
309    // finally send output
310    if ($conf['gzip_output'] && DOKU_HAS_GZIP) {
311        header('Vary: Accept-Encoding');
312        header('Content-Encoding: gzip');
313        echo gzencode($content, 9, FORCE_GZIP);
314    } else {
315        echo $content;
316    }
317}
318
319/**
320 * Fetches raw, unparsed POST data
321 *
322 * @return string
323 */
324function http_get_raw_post_data()
325{
326    static $postData = null;
327    if ($postData === null) {
328        $postData = file_get_contents('php://input');
329    }
330    return $postData;
331}
332
333/**
334 * Set the HTTP response status and takes care of the used PHP SAPI
335 *
336 * Inspired by CodeIgniter's set_status_header function
337 *
338 * @param int $code
339 * @param string $text
340 */
341function http_status($code = 200, $text = '')
342{
343    global $INPUT;
344
345    static $stati = [
346        200 => 'OK',
347        201 => 'Created',
348        202 => 'Accepted',
349        203 => 'Non-Authoritative Information',
350        204 => 'No Content',
351        205 => 'Reset Content',
352        206 => 'Partial Content',
353        300 => 'Multiple Choices',
354        301 => 'Moved Permanently',
355        302 => 'Found',
356        304 => 'Not Modified',
357        305 => 'Use Proxy',
358        307 => 'Temporary Redirect',
359        400 => 'Bad Request',
360        401 => 'Unauthorized',
361        403 => 'Forbidden',
362        404 => 'Not Found',
363        405 => 'Method Not Allowed',
364        406 => 'Not Acceptable',
365        407 => 'Proxy Authentication Required',
366        408 => 'Request Timeout',
367        409 => 'Conflict',
368        410 => 'Gone',
369        411 => 'Length Required',
370        412 => 'Precondition Failed',
371        413 => 'Request Entity Too Large',
372        414 => 'Request-URI Too Long',
373        415 => 'Unsupported Media Type',
374        416 => 'Requested Range Not Satisfiable',
375        417 => 'Expectation Failed',
376        500 => 'Internal Server Error',
377        501 => 'Not Implemented',
378        502 => 'Bad Gateway',
379        503 => 'Service Unavailable',
380        504 => 'Gateway Timeout',
381        505 => 'HTTP Version Not Supported'
382    ];
383
384    if ($text == '' && isset($stati[$code])) {
385        $text = $stati[$code];
386    }
387
388    $server_protocol = $INPUT->server->str('SERVER_PROTOCOL', false);
389
390    if (str_starts_with(PHP_SAPI, 'cgi') || defined('SIMPLE_TEST')) {
391        header("Status: {$code} {$text}", true);
392    } elseif ($server_protocol == 'HTTP/1.1' || $server_protocol == 'HTTP/1.0') {
393        header($server_protocol . " {$code} {$text}", true, $code);
394    } else {
395        header("HTTP/1.1 {$code} {$text}", true, $code);
396    }
397}
398