xref: /plugin/combo/action/staticresource.php (revision ad79af66a70046d40e27ff4cc82d28834afaf49b)
1c3437056SNickeau<?php
2c3437056SNickeau
304fd306cSNickeauuse ComboStrap\ExceptionCompile;
404fd306cSNickeauuse ComboStrap\ExceptionNotFound;
504fd306cSNickeauuse ComboStrap\ExecutionContext;
604fd306cSNickeauuse ComboStrap\FetcherRaster;
7c3437056SNickeauuse ComboStrap\FileSystems;
8c3437056SNickeauuse ComboStrap\Http;
9c3437056SNickeauuse ComboStrap\HttpResponse;
1004fd306cSNickeauuse ComboStrap\HttpResponseStatus;
114cadd4f8SNickeauuse ComboStrap\Identity;
1204fd306cSNickeauuse ComboStrap\IFetcher;
13c3437056SNickeauuse ComboStrap\LocalPath;
144cadd4f8SNickeauuse ComboStrap\LogUtility;
1504fd306cSNickeauuse ComboStrap\Mime;
16c3437056SNickeauuse ComboStrap\Path;
17c3437056SNickeauuse ComboStrap\PluginUtility;
18*ad79af66SNicouse ComboStrap\Site;
1904fd306cSNickeauuse ComboStrap\SiteConfig;
2004fd306cSNickeauuse ComboStrap\Web\Url;
21*ad79af66SNicouse ComboStrap\Web\UrlRewrite;
2204fd306cSNickeauuse ComboStrap\WikiPath;
23c3437056SNickeauuse dokuwiki\Utf8\PhpString;
24c3437056SNickeau
25c3437056SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
26c3437056SNickeau
27c3437056SNickeau/**
28c3437056SNickeau * Modify the serving of static resource via fetch.php
29c3437056SNickeau */
30c3437056SNickeauclass action_plugin_combo_staticresource extends DokuWiki_Action_Plugin
31c3437056SNickeau{
32c3437056SNickeau
33c3437056SNickeau
34c3437056SNickeau    /**
35c3437056SNickeau     * https://www.ietf.org/rfc/rfc2616.txt
36c3437056SNickeau     * To mark a response as "never expires," an origin server sends an Expires date approximately one year
37c3437056SNickeau     * from the time the response is sent.
38c3437056SNickeau     * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
39c3437056SNickeau     *
40c3437056SNickeau     * In seconds = 365*24*60*60
41c3437056SNickeau     */
42c3437056SNickeau    const INFINITE_MAX_AGE = 31536000;
43c3437056SNickeau
44c3437056SNickeau    const CANONICAL = "cache";
45c3437056SNickeau
46c3437056SNickeau    /**
4704fd306cSNickeau     * Enable an infinite cache on static resources (image, script, ...) with a {@link IFetcher::CACHE_BUSTER_KEY}
48c3437056SNickeau     */
49c3437056SNickeau    public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled";
5004fd306cSNickeau    const NO_TRANSFORM = "no-transform";
51c3437056SNickeau
52c3437056SNickeau
53c3437056SNickeau    /**
54c3437056SNickeau     * @param Doku_Event_Handler $controller
55c3437056SNickeau     */
56c3437056SNickeau    function register(Doku_Event_Handler $controller)
57c3437056SNickeau    {
58c3437056SNickeau
59c3437056SNickeau        /**
60c3437056SNickeau         * Redirect the combo resources to the good file path
61c3437056SNickeau         * https://www.dokuwiki.org/devel:event:fetch_media_status
62c3437056SNickeau         */
63c3437056SNickeau        $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array());
64c3437056SNickeau
65c3437056SNickeau        /**
66c3437056SNickeau         * Serve the image and static resources with HTTP cache control
67c3437056SNickeau         * https://www.dokuwiki.org/devel:event:media_sendfile
68c3437056SNickeau         */
69c3437056SNickeau        $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array());
70c3437056SNickeau
71c3437056SNickeau
72c3437056SNickeau    }
73c3437056SNickeau
7404fd306cSNickeau    /**
7504fd306cSNickeau     * @param Doku_Event $event
7604fd306cSNickeau     * https://www.dokuwiki.org/devel:event:fetch_media_status
7704fd306cSNickeau     */
78c3437056SNickeau    function handleMediaStatus(Doku_Event $event, $params)
79c3437056SNickeau    {
80c3437056SNickeau
8170bbd7f1Sgerardnico        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null;
8270bbd7f1Sgerardnico        $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null;
8304fd306cSNickeau        if ($drive === null && $fetcher === null) {
84c3437056SNickeau            return;
85c3437056SNickeau        }
8604fd306cSNickeau        if ($fetcher === FetcherRaster::CANONICAL) {
8704fd306cSNickeau            // not yet implemented
88c3437056SNickeau            return;
89c3437056SNickeau        }
9004fd306cSNickeau
9104fd306cSNickeau        /**
9204fd306cSNickeau         * Security
9304fd306cSNickeau         */
9404fd306cSNickeau        if ($drive === WikiPath::CACHE_DRIVE) {
954cadd4f8SNickeau            $event->data['download'] = false;
964cadd4f8SNickeau            if (!Identity::isManager()) {
9704fd306cSNickeau                $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED;
9804fd306cSNickeau                return;
994cadd4f8SNickeau            }
1004cadd4f8SNickeau        }
101c3437056SNickeau
10204fd306cSNickeau
10304fd306cSNickeau        /**
10404fd306cSNickeau         * Add the extra attributes
10504fd306cSNickeau         */
10604fd306cSNickeau        $fetchUrl = Url::createFromGetOrPostGlobalVariable();
10704fd306cSNickeau        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
10804fd306cSNickeau        try {
10904fd306cSNickeau
11004fd306cSNickeau            $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl);
11104fd306cSNickeau            $fetchPath = $fetcher->getFetchPath();
112*ad79af66SNico            $filePath = $fetchPath->toAbsoluteId();
113*ad79af66SNico            /**
114*ad79af66SNico             * Bug
115*ad79af66SNico             *
116*ad79af66SNico             * We have a bug with {@link WikiPath::toValidAbsolutePath} that uses {@link cleanID()}.
117*ad79af66SNico             * `/` becomes `_` if the useSlash conf is not enabled with web server useRewrite
118*ad79af66SNico             * and the file then does not exists.
119*ad79af66SNico             *
120*ad79af66SNico             * Furthermore, passing a file that does not exist, will break dokuwiki and returns a 500
121*ad79af66SNico             */
122*ad79af66SNico            if (!file_exists($fetchPath)) {
123*ad79af66SNico                $useRewrite = Site::getUrlRewrite();
124*ad79af66SNico                $useSlash = Site::getUseSlash();
125*ad79af66SNico                if($useRewrite == UrlRewrite::WEB_SERVER_REWRITE && !$useSlash){
126*ad79af66SNico                    $executionContext
127*ad79af66SNico                        ->response()
128*ad79af66SNico                        ->setStatus(400)
129*ad79af66SNico                        ->setBodyAsJsonMessage("The `useSlash` configuration should be enabled when the `useRewrite` is `htaccess` (ie web server), otherwise the file is not found.")
130*ad79af66SNico                        ->end();
131*ad79af66SNico                } else {
132*ad79af66SNico                    $executionContext
133*ad79af66SNico                        ->response()
134*ad79af66SNico                        ->setStatus(404)
135*ad79af66SNico                        ->end();
136*ad79af66SNico                }
137*ad79af66SNico                return;
138*ad79af66SNico            }
139*ad79af66SNico            $event->data['file'] = $filePath;
14004fd306cSNickeau            $event->data['status'] = HttpResponseStatus::ALL_GOOD;
14104fd306cSNickeau            $mime = $fetcher->getMime();
14204fd306cSNickeau            $event->data["mime"] = $mime->toString();
14304fd306cSNickeau            /**
14404fd306cSNickeau             * TODO: set download as parameter of the fetch url
14504fd306cSNickeau             */
14604fd306cSNickeau            if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) {
14704fd306cSNickeau                $event->data['download'] = false;
14804fd306cSNickeau            } else {
14904fd306cSNickeau                $event->data['download'] = true;
15004fd306cSNickeau            }
15104fd306cSNickeau            $event->data['statusmessage'] = '';
15204fd306cSNickeau        } catch (\Exception $e) {
15304fd306cSNickeau
15404fd306cSNickeau            $executionContext
15504fd306cSNickeau                ->response()
15604fd306cSNickeau                ->setException($e)
15704fd306cSNickeau                ->setStatusAndBodyFromException($e)
15804fd306cSNickeau                ->end();
15904fd306cSNickeau
16004fd306cSNickeau            //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId();
16104fd306cSNickeau            $event->data['file'] = "error.json";
16204fd306cSNickeau            $event->data['statusmessage'] = $e->getMessage();
16304fd306cSNickeau            //$event->data['status'] = $httpResponse->getStatus();
16404fd306cSNickeau            $event->data['mime'] = Mime::JSON;
16504fd306cSNickeau
16604fd306cSNickeau
16704fd306cSNickeau        }
16804fd306cSNickeau
169c3437056SNickeau    }
170c3437056SNickeau
171c3437056SNickeau    function handleSendFile(Doku_Event $event, $params)
172c3437056SNickeau    {
173c3437056SNickeau
17404fd306cSNickeau        if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) {
17504fd306cSNickeau            // when there is an error for instance
17604fd306cSNickeau            return;
17704fd306cSNickeau        }
178c3437056SNickeau        /**
1794cadd4f8SNickeau         * If there is no buster key, the infinite cache is off
180c3437056SNickeau         */
1810763546aSNico        $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY] ?? null;
1824cadd4f8SNickeau        if ($busterKey === null) {
1834cadd4f8SNickeau            return;
1844cadd4f8SNickeau        }
1854cadd4f8SNickeau
1864cadd4f8SNickeau        /**
1874cadd4f8SNickeau         * The media to send
1884cadd4f8SNickeau         */
1894cadd4f8SNickeau        $originalFile = $event->data["orig"]; // the original file
19004fd306cSNickeau        $physicalFile = $event->data["file"]; // the file modified or the file to send
1914cadd4f8SNickeau        if (empty($physicalFile)) {
1924cadd4f8SNickeau            $physicalFile = $originalFile;
1934cadd4f8SNickeau        }
19404fd306cSNickeau        $mediaToSend = LocalPath::createFromPathString($physicalFile);
1954cadd4f8SNickeau        if (!FileSystems::exists($mediaToSend)) {
19604fd306cSNickeau            if (PluginUtility::isDevOrTest()) {
19704fd306cSNickeau                LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL);
19804fd306cSNickeau            }
1994cadd4f8SNickeau            return;
2004cadd4f8SNickeau        }
201c3437056SNickeau
202c3437056SNickeau        /**
203c3437056SNickeau         * Combo Media
204c3437056SNickeau         * (Static file from the combo resources are always taken over)
205c3437056SNickeau         */
20670bbd7f1Sgerardnico        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null;
2074cadd4f8SNickeau        if ($drive === null) {
208c3437056SNickeau
20904fd306cSNickeau            $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1);
2104cadd4f8SNickeau            if (!$confValue) {
211c3437056SNickeau                return;
212c3437056SNickeau            }
213c3437056SNickeau
2144cadd4f8SNickeau            try {
21504fd306cSNickeau                $dokuPath = $mediaToSend->toWikiPath();
21604fd306cSNickeau            } catch (ExceptionCompile $e) {
2174cadd4f8SNickeau                // not a dokuwiki file ?
2184cadd4f8SNickeau                LogUtility::msg("Error: {$e->getMessage()}");
2194cadd4f8SNickeau                return;
2204cadd4f8SNickeau            }
2214cadd4f8SNickeau            if (!$dokuPath->isPublic()) {
2224cadd4f8SNickeau                return; // Infinite static is only for public media
2234cadd4f8SNickeau            }
2244cadd4f8SNickeau
2254cadd4f8SNickeau        }
2264cadd4f8SNickeau
227c3437056SNickeau        /**
228c3437056SNickeau         * We take over the complete {@link sendFile()} function and exit
229c3437056SNickeau         *
230c3437056SNickeau         * in {@link sendFile()}, DokuWiki set the `Cache-Control` and
231c3437056SNickeau         * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()}
232c3437056SNickeau         * Meaning that the AFTER event is never reached
233c3437056SNickeau         * that we can't send a cache control as below
234c3437056SNickeau         * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge");
235c3437056SNickeau         *
236c3437056SNickeau         * We take the control over then
237c3437056SNickeau         */
238c3437056SNickeau
239c3437056SNickeau
240c3437056SNickeau        /**
241c3437056SNickeau         * The cache instructions
242c3437056SNickeau         */
243c3437056SNickeau        $infiniteMaxAge = self::INFINITE_MAX_AGE;
244c3437056SNickeau        $expires = time() + $infiniteMaxAge;
245c3437056SNickeau        header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT');
246c3437056SNickeau        $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"];
24704fd306cSNickeau        try {
248c3437056SNickeau            if ($mediaToSend->getExtension() === "js") {
249c3437056SNickeau                // if a SRI is given and that a proxy is
250c3437056SNickeau                // reducing javascript, it will not match
251c3437056SNickeau                // no-transform will avoid that
25204fd306cSNickeau                $cacheControlDirective[] = self::NO_TRANSFORM;
25304fd306cSNickeau            }
25404fd306cSNickeau        } catch (ExceptionNotFound $e) {
25504fd306cSNickeau            LogUtility::warning("The media ($mediaToSend) does not have any extension.");
256c3437056SNickeau        }
257c3437056SNickeau        header("Cache-Control: " . implode(", ", $cacheControlDirective));
258c3437056SNickeau        Http::removeHeaderIfPresent("Pragma");
259c3437056SNickeau
26004fd306cSNickeau        $excutingContext = ExecutionContext::getActualOrCreateFromEnv();
261c3437056SNickeau        /**
262c3437056SNickeau         * The Etag cache validator
263c3437056SNickeau         *
264c3437056SNickeau         * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of
265c3437056SNickeau         * the file but we need to add the parameters also because they
266c3437056SNickeau         * are generated image
267c3437056SNickeau         *
268c3437056SNickeau         * Last-Modified is not needed for the same reason
269c3437056SNickeau         *
270c3437056SNickeau         */
27104fd306cSNickeau        try {
272c3437056SNickeau            $etag = self::getEtagValue($mediaToSend, $_REQUEST);
273c3437056SNickeau            header("ETag: $etag");
27404fd306cSNickeau        } catch (ExceptionNotFound $e) {
27504fd306cSNickeau            // internal error
27604fd306cSNickeau            $excutingContext->response()
27704fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
27804fd306cSNickeau                ->setEvent($event)
27904fd306cSNickeau                ->setCanonical(self::CANONICAL)
28004fd306cSNickeau                ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}")
28104fd306cSNickeau                ->end();
28204fd306cSNickeau            return;
28304fd306cSNickeau        }
28404fd306cSNickeau
285c3437056SNickeau
286c3437056SNickeau        /**
287c3437056SNickeau         * Conditional Request ?
288c3437056SNickeau         * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless
289c3437056SNickeau         */
290c3437056SNickeau        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
291c3437056SNickeau            $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']);
292c3437056SNickeau            if ($ifNoneMatch && $ifNoneMatch === $etag) {
29334c746fbSgerardnico                /**
29434c746fbSgerardnico                 * Don't add a body
29534c746fbSgerardnico                 */
29604fd306cSNickeau                $excutingContext
29704fd306cSNickeau                    ->response()
29804fd306cSNickeau                    ->setStatus(HttpResponseStatus::NOT_MODIFIED)
299c3437056SNickeau                    ->setEvent($event)
300c3437056SNickeau                    ->setCanonical(self::CANONICAL)
30104fd306cSNickeau                    ->end();
302c3437056SNickeau                return;
303c3437056SNickeau            }
304c3437056SNickeau        }
305c3437056SNickeau
306c3437056SNickeau
307c3437056SNickeau        /**
308c3437056SNickeau         * Download or display feature
309c3437056SNickeau         * (Taken over from SendFile)
310c3437056SNickeau         */
31104fd306cSNickeau        try {
31204fd306cSNickeau            $mime = FileSystems::getMime($mediaToSend);
31304fd306cSNickeau        } catch (ExceptionNotFound $e) {
31404fd306cSNickeau            $excutingContext->response()
31504fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
31604fd306cSNickeau                ->setEvent($event)
31704fd306cSNickeau                ->setCanonical(self::CANONICAL)
31804fd306cSNickeau                ->setBodyAsJsonMessage("Mime not found")
31904fd306cSNickeau                ->end();
32004fd306cSNickeau            return;
32104fd306cSNickeau        }
322c3437056SNickeau        $download = $event->data["download"];
323c3437056SNickeau        if ($download && $mime->toString() !== "image/svg+xml") {
324c3437056SNickeau            header('Content-Disposition: attachment;' . rfc2231_encode(
325c3437056SNickeau                    'filename', PhpString::basename($originalFile)) . ';'
326c3437056SNickeau            );
327c3437056SNickeau        } else {
328c3437056SNickeau            header('Content-Disposition: inline;' . rfc2231_encode(
329c3437056SNickeau                    'filename', PhpString::basename($originalFile)) . ';'
330c3437056SNickeau            );
331c3437056SNickeau        }
332c3437056SNickeau
333c3437056SNickeau        /**
334c3437056SNickeau         * The vary header avoid caching
335c3437056SNickeau         * Delete it
336c3437056SNickeau         */
337c3437056SNickeau        action_plugin_combo_cache::deleteVaryHeader();
338c3437056SNickeau
339c3437056SNickeau        /**
340c3437056SNickeau         * Use x-sendfile header to pass the delivery to compatible web servers
341c3437056SNickeau         * (Taken over from SendFile)
342c3437056SNickeau         */
34304fd306cSNickeau        http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId());
344c3437056SNickeau
345c3437056SNickeau        /**
346c3437056SNickeau         * Send the file
347c3437056SNickeau         */
34804fd306cSNickeau        $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb");
349c3437056SNickeau        if ($filePointer) {
350c3437056SNickeau            http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString());
351c3437056SNickeau            /**
352c3437056SNickeau             * The {@link http_rangeRequest} exit not on test
353c3437056SNickeau             * Trying to stop the dokuwiki processing of {@link sendFile()}
354c3437056SNickeau             * Until {@link HttpResponse} can send resource
355c3437056SNickeau             * TODO: integrate it in {@link HttpResponse}
356c3437056SNickeau             */
357c3437056SNickeau            if (PluginUtility::isDevOrTest()) {
358c3437056SNickeau                /**
359c3437056SNickeau                 * Add test info into the request
360c3437056SNickeau                 */
361c3437056SNickeau                $testRequest = TestRequest::getRunning();
362c3437056SNickeau
363c3437056SNickeau                if ($testRequest !== null) {
364c3437056SNickeau                    $testRequest->addData(HttpResponse::EXIT_KEY, "File Send");
365c3437056SNickeau                }
366c3437056SNickeau                if ($event !== null) {
367c3437056SNickeau                    $event->stopPropagation();
368c3437056SNickeau                    $event->preventDefault();
369c3437056SNickeau                }
370c3437056SNickeau            }
371c3437056SNickeau        } else {
37204fd306cSNickeau            $excutingContext->response()
37304fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
37404fd306cSNickeau                ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?")
37504fd306cSNickeau                ->end();
376c3437056SNickeau        }
377c3437056SNickeau
378c3437056SNickeau    }
379c3437056SNickeau
380c3437056SNickeau    /**
381c3437056SNickeau     * @param Path $mediaFile
382c3437056SNickeau     * @param Array $properties - the query properties
383c3437056SNickeau     * @return string
38404fd306cSNickeau     * @throws ExceptionNotFound
385c3437056SNickeau     */
386c3437056SNickeau    public
387c3437056SNickeau    static function getEtagValue(Path $mediaFile, array $properties): string
388c3437056SNickeau    {
38904fd306cSNickeau        clearstatcache();
390c3437056SNickeau        $etagString = FileSystems::getModifiedTime($mediaFile)->format('r');
391c3437056SNickeau        ksort($properties);
392c3437056SNickeau        foreach ($properties as $key => $value) {
39304fd306cSNickeau
394c3437056SNickeau            /**
395c3437056SNickeau             * Media is already on the URL
396c3437056SNickeau             * tok is just added when w and h are on the url
397c3437056SNickeau             * Buster is the timestamp
398c3437056SNickeau             */
39904fd306cSNickeau            if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) {
400c3437056SNickeau                continue;
401c3437056SNickeau            }
402c3437056SNickeau            /**
403c3437056SNickeau             * If empty means not used
404c3437056SNickeau             */
40504fd306cSNickeau            if (trim($value) === "") {
406c3437056SNickeau                continue;
407c3437056SNickeau            }
408c3437056SNickeau            $etagString .= "$key=$value";
409c3437056SNickeau        }
410c3437056SNickeau        return '"' . md5($etagString) . '"';
411c3437056SNickeau    }
412c3437056SNickeau
413c3437056SNickeau
414c3437056SNickeau}
415