xref: /plugin/combo/action/staticresource.php (revision 45a874f4355f8bee7459e5d3b79e86e68468b316)
1<?php
2
3use ComboStrap\ExceptionCompile;
4use ComboStrap\ExceptionNotFound;
5use ComboStrap\ExecutionContext;
6use ComboStrap\FetcherRaster;
7use ComboStrap\FileSystems;
8use ComboStrap\Http;
9use ComboStrap\HttpResponse;
10use ComboStrap\HttpResponseStatus;
11use ComboStrap\Identity;
12use ComboStrap\IFetcher;
13use ComboStrap\LocalPath;
14use ComboStrap\LogUtility;
15use ComboStrap\Mime;
16use ComboStrap\Path;
17use ComboStrap\PluginUtility;
18use ComboStrap\Site;
19use ComboStrap\SiteConfig;
20use ComboStrap\Web\Url;
21use ComboStrap\Web\UrlRewrite;
22use ComboStrap\WikiPath;
23use dokuwiki\Utf8\PhpString;
24
25require_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
26
27/**
28 * Modify the serving of static resource via fetch.php
29 */
30class action_plugin_combo_staticresource extends DokuWiki_Action_Plugin
31{
32
33
34    /**
35     * https://www.ietf.org/rfc/rfc2616.txt
36     * To mark a response as "never expires," an origin server sends an Expires date approximately one year
37     * from the time the response is sent.
38     * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
39     *
40     * In seconds = 365*24*60*60
41     */
42    const INFINITE_MAX_AGE = 31536000;
43
44    const CANONICAL = "cache";
45
46    /**
47     * Enable an infinite cache on static resources (image, script, ...) with a {@link IFetcher::CACHE_BUSTER_KEY}
48     */
49    public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled";
50    const NO_TRANSFORM = "no-transform";
51
52
53    /**
54     * @param Doku_Event_Handler $controller
55     */
56    function register(Doku_Event_Handler $controller)
57    {
58
59        /**
60         * Redirect the combo resources to the good file path
61         * https://www.dokuwiki.org/devel:event:fetch_media_status
62         */
63        $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array());
64
65        /**
66         * Serve the image and static resources with HTTP cache control
67         * https://www.dokuwiki.org/devel:event:media_sendfile
68         */
69        $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array());
70
71
72    }
73
74    /**
75     * @param Doku_Event $event
76     * https://www.dokuwiki.org/devel:event:fetch_media_status
77     */
78    function handleMediaStatus(Doku_Event $event, $params)
79    {
80
81        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null;
82        $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null;
83        if ($drive === null && $fetcher === null) {
84            return;
85        }
86        if ($fetcher === FetcherRaster::CANONICAL) {
87            // not yet implemented
88            return;
89        }
90
91        /**
92         * Security
93         */
94        if ($drive === WikiPath::CACHE_DRIVE) {
95            $event->data['download'] = false;
96            if (!Identity::isManager()) {
97                $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED;
98                return;
99            }
100        }
101
102
103        /**
104         * Add the extra attributes
105         */
106        $fetchUrl = Url::createFromGetOrPostGlobalVariable();
107        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
108        try {
109
110            $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl);
111            $fetchPath = $fetcher->getFetchPath();
112            $filePath = $fetchPath->toAbsoluteId();
113            /**
114             * Bug
115             *
116             * We have a bug with {@link WikiPath::toValidAbsolutePath} that uses {@link cleanID()}.
117             * `/` becomes `_` if the useSlash conf is not enabled with web server useRewrite
118             * and the file then does not exists.
119             *
120             * Furthermore, passing a file that does not exist, will break dokuwiki and returns a 500
121             */
122            if (!file_exists($fetchPath)) {
123                $useRewrite = Site::getUrlRewrite();
124                $useSlash = Site::getUseSlash();
125                if($useRewrite == UrlRewrite::WEB_SERVER_REWRITE && !$useSlash){
126                    $executionContext
127                        ->response()
128                        ->setStatus(400)
129                        ->setBodyAsJsonMessage("The `useSlash` configuration should be enabled when the `useRewrite` is `htaccess` (ie web server), otherwise the file is not found.")
130                        ->end();
131                } else {
132                    $executionContext
133                        ->response()
134                        ->setStatus(404)
135                        ->end();
136                }
137                return;
138            }
139            $event->data['file'] = $filePath;
140            $event->data['status'] = HttpResponseStatus::ALL_GOOD;
141            $mime = $fetcher->getMime();
142            $event->data["mime"] = $mime->toString();
143            /**
144             * TODO: set download as parameter of the fetch url
145             */
146            if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) {
147                $event->data['download'] = false;
148            } else {
149                $event->data['download'] = true;
150            }
151            $event->data['statusmessage'] = '';
152        } catch (\Exception $e) {
153
154            $executionContext
155                ->response()
156                ->setException($e)
157                ->setStatusAndBodyFromException($e)
158                ->end();
159
160            //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId();
161            $event->data['file'] = "error.json";
162            $event->data['statusmessage'] = $e->getMessage();
163            //$event->data['status'] = $httpResponse->getStatus();
164            $event->data['mime'] = Mime::JSON;
165
166
167        }
168
169    }
170
171    function handleSendFile(Doku_Event $event, $params)
172    {
173
174        if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) {
175            // when there is an error for instance
176            return;
177        }
178        /**
179         * If there is no buster key, the infinite cache is off
180         */
181        $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY] ?? null;
182        if ($busterKey === null) {
183            return;
184        }
185
186        /**
187         * The media to send
188         */
189        $originalFile = $event->data["orig"]; // the original file
190        $physicalFile = $event->data["file"]; // the file modified or the file to send
191        if (empty($physicalFile)) {
192            $physicalFile = $originalFile;
193        }
194        $mediaToSend = LocalPath::createFromPathString($physicalFile);
195        if (!FileSystems::exists($mediaToSend)) {
196            if (PluginUtility::isDevOrTest()) {
197                LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL);
198            }
199            return;
200        }
201
202        /**
203         * Combo Media
204         * (Static file from the combo resources are always taken over)
205         */
206        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null;
207        if ($drive === null) {
208
209            $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1);
210            if (!$confValue) {
211                return;
212            }
213
214            try {
215                $dokuPath = $mediaToSend->toWikiPath();
216            } catch (ExceptionCompile $e) {
217                // not a dokuwiki file ?
218                LogUtility::msg("Error: {$e->getMessage()}");
219                return;
220            }
221            if (!$dokuPath->isPublic()) {
222                return; // Infinite static is only for public media
223            }
224
225        }
226
227        /**
228         * We take over the complete {@link sendFile()} function and exit
229         *
230         * in {@link sendFile()}, DokuWiki set the `Cache-Control` and
231         * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()}
232         * Meaning that the AFTER event is never reached
233         * that we can't send a cache control as below
234         * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge");
235         *
236         * We take the control over then
237         */
238
239
240        /**
241         * The cache instructions
242         */
243        $infiniteMaxAge = self::INFINITE_MAX_AGE;
244        $expires = time() + $infiniteMaxAge;
245        header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT');
246        $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"];
247        try {
248            if ($mediaToSend->getExtension() === "js") {
249                // if a SRI is given and that a proxy is
250                // reducing javascript, it will not match
251                // no-transform will avoid that
252                $cacheControlDirective[] = self::NO_TRANSFORM;
253            }
254        } catch (ExceptionNotFound $e) {
255            LogUtility::warning("The media ($mediaToSend) does not have any extension.");
256        }
257        header("Cache-Control: " . implode(", ", $cacheControlDirective));
258        Http::removeHeaderIfPresent("Pragma");
259
260        $excutingContext = ExecutionContext::getActualOrCreateFromEnv();
261        /**
262         * The Etag cache validator
263         *
264         * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of
265         * the file but we need to add the parameters also because they
266         * are generated image
267         *
268         * Last-Modified is not needed for the same reason
269         *
270         */
271        try {
272            $etag = self::getEtagValue($mediaToSend, $_REQUEST);
273            header("ETag: $etag");
274        } catch (ExceptionNotFound $e) {
275            // internal error
276            $excutingContext->response()
277                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
278                ->setEvent($event)
279                ->setCanonical(self::CANONICAL)
280                ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}")
281                ->end();
282            return;
283        }
284
285
286        /**
287         * Conditional Request ?
288         * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless
289         */
290        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
291            $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']);
292            if ($ifNoneMatch && $ifNoneMatch === $etag) {
293                /**
294                 * Don't add a body
295                 */
296                $excutingContext
297                    ->response()
298                    ->setStatus(HttpResponseStatus::NOT_MODIFIED)
299                    ->setEvent($event)
300                    ->setCanonical(self::CANONICAL)
301                    ->end();
302                return;
303            }
304        }
305
306
307        /**
308         * Download or display feature
309         * (Taken over from SendFile)
310         */
311        try {
312            $mime = FileSystems::getMime($mediaToSend);
313        } catch (ExceptionNotFound $e) {
314            $excutingContext->response()
315                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
316                ->setEvent($event)
317                ->setCanonical(self::CANONICAL)
318                ->setBodyAsJsonMessage("Mime not found")
319                ->end();
320            return;
321        }
322        $download = $event->data["download"];
323        if ($download && $mime->toString() !== "image/svg+xml") {
324            header('Content-Disposition: attachment;' . rfc2231_encode(
325                    'filename', PhpString::basename($originalFile)) . ';'
326            );
327        } else {
328            header('Content-Disposition: inline;' . rfc2231_encode(
329                    'filename', PhpString::basename($originalFile)) . ';'
330            );
331        }
332
333        /**
334         * The vary header avoid caching
335         * Delete it
336         */
337        action_plugin_combo_cache::deleteVaryHeader();
338
339        /**
340         * Use x-sendfile header to pass the delivery to compatible web servers
341         * (Taken over from SendFile)
342         */
343        http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId());
344
345        /**
346         * Send the file
347         */
348        $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb");
349        if ($filePointer) {
350            http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString());
351            /**
352             * The {@link http_rangeRequest} exit not on test
353             * Trying to stop the dokuwiki processing of {@link sendFile()}
354             * Until {@link HttpResponse} can send resource
355             * TODO: integrate it in {@link HttpResponse}
356             */
357            if (PluginUtility::isDevOrTest()) {
358                /**
359                 * Add test info into the request
360                 */
361                $testRequest = TestRequest::getRunning();
362
363                if ($testRequest !== null) {
364                    $testRequest->addData(HttpResponse::EXIT_KEY, "File Send");
365                }
366                if ($event !== null) {
367                    $event->stopPropagation();
368                    $event->preventDefault();
369                }
370            }
371        } else {
372            $excutingContext->response()
373                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
374                ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?")
375                ->end();
376        }
377
378    }
379
380    /**
381     * @param Path $mediaFile
382     * @param Array $properties - the query properties
383     * @return string
384     * @throws ExceptionNotFound
385     */
386    public
387    static function getEtagValue(Path $mediaFile, array $properties): string
388    {
389        clearstatcache();
390        $etagString = FileSystems::getModifiedTime($mediaFile)->format('r');
391        ksort($properties);
392        foreach ($properties as $key => $value) {
393
394            /**
395             * Media is already on the URL
396             * tok is just added when w and h are on the url
397             * Buster is the timestamp
398             */
399            if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) {
400                continue;
401            }
402            /**
403             * If empty means not used
404             */
405            if (trim($value) === "") {
406                continue;
407            }
408            $etagString .= "$key=$value";
409        }
410        return '"' . md5($etagString) . '"';
411    }
412
413
414}
415