xref: /plugin/combo/action/staticresource.php (revision 70bbd7f1f72440223cc13f3495efdcb2b0a11514)
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;
1804fd306cSNickeauuse ComboStrap\SiteConfig;
1904fd306cSNickeauuse ComboStrap\Web\Url;
2004fd306cSNickeauuse ComboStrap\WikiPath;
21c3437056SNickeauuse dokuwiki\Utf8\PhpString;
22c3437056SNickeau
23c3437056SNickeaurequire_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
24c3437056SNickeau
25c3437056SNickeau/**
26c3437056SNickeau * Modify the serving of static resource via fetch.php
27c3437056SNickeau */
28c3437056SNickeauclass action_plugin_combo_staticresource extends DokuWiki_Action_Plugin
29c3437056SNickeau{
30c3437056SNickeau
31c3437056SNickeau
32c3437056SNickeau    /**
33c3437056SNickeau     * https://www.ietf.org/rfc/rfc2616.txt
34c3437056SNickeau     * To mark a response as "never expires," an origin server sends an Expires date approximately one year
35c3437056SNickeau     * from the time the response is sent.
36c3437056SNickeau     * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
37c3437056SNickeau     *
38c3437056SNickeau     * In seconds = 365*24*60*60
39c3437056SNickeau     */
40c3437056SNickeau    const INFINITE_MAX_AGE = 31536000;
41c3437056SNickeau
42c3437056SNickeau    const CANONICAL = "cache";
43c3437056SNickeau
44c3437056SNickeau    /**
4504fd306cSNickeau     * Enable an infinite cache on static resources (image, script, ...) with a {@link IFetcher::CACHE_BUSTER_KEY}
46c3437056SNickeau     */
47c3437056SNickeau    public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled";
4804fd306cSNickeau    const NO_TRANSFORM = "no-transform";
49c3437056SNickeau
50c3437056SNickeau
51c3437056SNickeau    /**
52c3437056SNickeau     * @param Doku_Event_Handler $controller
53c3437056SNickeau     */
54c3437056SNickeau    function register(Doku_Event_Handler $controller)
55c3437056SNickeau    {
56c3437056SNickeau
57c3437056SNickeau        /**
58c3437056SNickeau         * Redirect the combo resources to the good file path
59c3437056SNickeau         * https://www.dokuwiki.org/devel:event:fetch_media_status
60c3437056SNickeau         */
61c3437056SNickeau        $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array());
62c3437056SNickeau
63c3437056SNickeau        /**
64c3437056SNickeau         * Serve the image and static resources with HTTP cache control
65c3437056SNickeau         * https://www.dokuwiki.org/devel:event:media_sendfile
66c3437056SNickeau         */
67c3437056SNickeau        $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array());
68c3437056SNickeau
69c3437056SNickeau
70c3437056SNickeau    }
71c3437056SNickeau
7204fd306cSNickeau    /**
7304fd306cSNickeau     * @param Doku_Event $event
7404fd306cSNickeau     * https://www.dokuwiki.org/devel:event:fetch_media_status
7504fd306cSNickeau     */
76c3437056SNickeau    function handleMediaStatus(Doku_Event $event, $params)
77c3437056SNickeau    {
78c3437056SNickeau
79*70bbd7f1Sgerardnico        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null;
80*70bbd7f1Sgerardnico        $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null;
8104fd306cSNickeau        if ($drive === null && $fetcher === null) {
82c3437056SNickeau            return;
83c3437056SNickeau        }
8404fd306cSNickeau        if ($fetcher === FetcherRaster::CANONICAL) {
8504fd306cSNickeau            // not yet implemented
86c3437056SNickeau            return;
87c3437056SNickeau        }
8804fd306cSNickeau
8904fd306cSNickeau        /**
9004fd306cSNickeau         * Security
9104fd306cSNickeau         */
9204fd306cSNickeau        if ($drive === WikiPath::CACHE_DRIVE) {
934cadd4f8SNickeau            $event->data['download'] = false;
944cadd4f8SNickeau            if (!Identity::isManager()) {
9504fd306cSNickeau                $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED;
9604fd306cSNickeau                return;
974cadd4f8SNickeau            }
984cadd4f8SNickeau        }
99c3437056SNickeau
10004fd306cSNickeau
10104fd306cSNickeau        /**
10204fd306cSNickeau         * Add the extra attributes
10304fd306cSNickeau         */
10404fd306cSNickeau        $fetchUrl = Url::createFromGetOrPostGlobalVariable();
10504fd306cSNickeau        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
10604fd306cSNickeau        try {
10704fd306cSNickeau
10804fd306cSNickeau            $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl);
10904fd306cSNickeau            $fetchPath = $fetcher->getFetchPath();
11004fd306cSNickeau            $event->data['file'] = $fetchPath->toAbsoluteId();
11104fd306cSNickeau            $event->data['status'] = HttpResponseStatus::ALL_GOOD;
11204fd306cSNickeau            $mime = $fetcher->getMime();
11304fd306cSNickeau            $event->data["mime"] = $mime->toString();
11404fd306cSNickeau            /**
11504fd306cSNickeau             * TODO: set download as parameter of the fetch url
11604fd306cSNickeau             */
11704fd306cSNickeau            if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) {
11804fd306cSNickeau                $event->data['download'] = false;
11904fd306cSNickeau            } else {
12004fd306cSNickeau                $event->data['download'] = true;
12104fd306cSNickeau            }
12204fd306cSNickeau            $event->data['statusmessage'] = '';
12304fd306cSNickeau        } catch (\Exception $e) {
12404fd306cSNickeau
12504fd306cSNickeau            $executionContext
12604fd306cSNickeau                ->response()
12704fd306cSNickeau                ->setException($e)
12804fd306cSNickeau                ->setStatusAndBodyFromException($e)
12904fd306cSNickeau                ->end();
13004fd306cSNickeau
13104fd306cSNickeau            //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId();
13204fd306cSNickeau            $event->data['file'] = "error.json";
13304fd306cSNickeau            $event->data['statusmessage'] = $e->getMessage();
13404fd306cSNickeau            //$event->data['status'] = $httpResponse->getStatus();
13504fd306cSNickeau            $event->data['mime'] = Mime::JSON;
13604fd306cSNickeau
13704fd306cSNickeau
13804fd306cSNickeau        }
13904fd306cSNickeau
140c3437056SNickeau    }
141c3437056SNickeau
142c3437056SNickeau    function handleSendFile(Doku_Event $event, $params)
143c3437056SNickeau    {
144c3437056SNickeau
14504fd306cSNickeau        if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) {
14604fd306cSNickeau            // when there is an error for instance
14704fd306cSNickeau            return;
14804fd306cSNickeau        }
149c3437056SNickeau        /**
1504cadd4f8SNickeau         * If there is no buster key, the infinite cache is off
151c3437056SNickeau         */
15204fd306cSNickeau        $busterKey = $_GET[IFetcher::CACHE_BUSTER_KEY];
1534cadd4f8SNickeau        if ($busterKey === null) {
1544cadd4f8SNickeau            return;
1554cadd4f8SNickeau        }
1564cadd4f8SNickeau
1574cadd4f8SNickeau        /**
1584cadd4f8SNickeau         * The media to send
1594cadd4f8SNickeau         */
1604cadd4f8SNickeau        $originalFile = $event->data["orig"]; // the original file
16104fd306cSNickeau        $physicalFile = $event->data["file"]; // the file modified or the file to send
1624cadd4f8SNickeau        if (empty($physicalFile)) {
1634cadd4f8SNickeau            $physicalFile = $originalFile;
1644cadd4f8SNickeau        }
16504fd306cSNickeau        $mediaToSend = LocalPath::createFromPathString($physicalFile);
1664cadd4f8SNickeau        if (!FileSystems::exists($mediaToSend)) {
16704fd306cSNickeau            if (PluginUtility::isDevOrTest()) {
16804fd306cSNickeau                LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL);
16904fd306cSNickeau            }
1704cadd4f8SNickeau            return;
1714cadd4f8SNickeau        }
172c3437056SNickeau
173c3437056SNickeau        /**
174c3437056SNickeau         * Combo Media
175c3437056SNickeau         * (Static file from the combo resources are always taken over)
176c3437056SNickeau         */
177*70bbd7f1Sgerardnico        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE] ?? null;
1784cadd4f8SNickeau        if ($drive === null) {
179c3437056SNickeau
18004fd306cSNickeau            $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1);
1814cadd4f8SNickeau            if (!$confValue) {
182c3437056SNickeau                return;
183c3437056SNickeau            }
184c3437056SNickeau
1854cadd4f8SNickeau            try {
18604fd306cSNickeau                $dokuPath = $mediaToSend->toWikiPath();
18704fd306cSNickeau            } catch (ExceptionCompile $e) {
1884cadd4f8SNickeau                // not a dokuwiki file ?
1894cadd4f8SNickeau                LogUtility::msg("Error: {$e->getMessage()}");
1904cadd4f8SNickeau                return;
1914cadd4f8SNickeau            }
1924cadd4f8SNickeau            if (!$dokuPath->isPublic()) {
1934cadd4f8SNickeau                return; // Infinite static is only for public media
1944cadd4f8SNickeau            }
1954cadd4f8SNickeau
1964cadd4f8SNickeau        }
1974cadd4f8SNickeau
198c3437056SNickeau        /**
199c3437056SNickeau         * We take over the complete {@link sendFile()} function and exit
200c3437056SNickeau         *
201c3437056SNickeau         * in {@link sendFile()}, DokuWiki set the `Cache-Control` and
202c3437056SNickeau         * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()}
203c3437056SNickeau         * Meaning that the AFTER event is never reached
204c3437056SNickeau         * that we can't send a cache control as below
205c3437056SNickeau         * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge");
206c3437056SNickeau         *
207c3437056SNickeau         * We take the control over then
208c3437056SNickeau         */
209c3437056SNickeau
210c3437056SNickeau
211c3437056SNickeau        /**
212c3437056SNickeau         * The cache instructions
213c3437056SNickeau         */
214c3437056SNickeau        $infiniteMaxAge = self::INFINITE_MAX_AGE;
215c3437056SNickeau        $expires = time() + $infiniteMaxAge;
216c3437056SNickeau        header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT');
217c3437056SNickeau        $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"];
21804fd306cSNickeau        try {
219c3437056SNickeau            if ($mediaToSend->getExtension() === "js") {
220c3437056SNickeau                // if a SRI is given and that a proxy is
221c3437056SNickeau                // reducing javascript, it will not match
222c3437056SNickeau                // no-transform will avoid that
22304fd306cSNickeau                $cacheControlDirective[] = self::NO_TRANSFORM;
22404fd306cSNickeau            }
22504fd306cSNickeau        } catch (ExceptionNotFound $e) {
22604fd306cSNickeau            LogUtility::warning("The media ($mediaToSend) does not have any extension.");
227c3437056SNickeau        }
228c3437056SNickeau        header("Cache-Control: " . implode(", ", $cacheControlDirective));
229c3437056SNickeau        Http::removeHeaderIfPresent("Pragma");
230c3437056SNickeau
23104fd306cSNickeau        $excutingContext = ExecutionContext::getActualOrCreateFromEnv();
232c3437056SNickeau        /**
233c3437056SNickeau         * The Etag cache validator
234c3437056SNickeau         *
235c3437056SNickeau         * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of
236c3437056SNickeau         * the file but we need to add the parameters also because they
237c3437056SNickeau         * are generated image
238c3437056SNickeau         *
239c3437056SNickeau         * Last-Modified is not needed for the same reason
240c3437056SNickeau         *
241c3437056SNickeau         */
24204fd306cSNickeau        try {
243c3437056SNickeau            $etag = self::getEtagValue($mediaToSend, $_REQUEST);
244c3437056SNickeau            header("ETag: $etag");
24504fd306cSNickeau        } catch (ExceptionNotFound $e) {
24604fd306cSNickeau            // internal error
24704fd306cSNickeau            $excutingContext->response()
24804fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
24904fd306cSNickeau                ->setEvent($event)
25004fd306cSNickeau                ->setCanonical(self::CANONICAL)
25104fd306cSNickeau                ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}")
25204fd306cSNickeau                ->end();
25304fd306cSNickeau            return;
25404fd306cSNickeau        }
25504fd306cSNickeau
256c3437056SNickeau
257c3437056SNickeau        /**
258c3437056SNickeau         * Conditional Request ?
259c3437056SNickeau         * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless
260c3437056SNickeau         */
261c3437056SNickeau        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
262c3437056SNickeau            $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']);
263c3437056SNickeau            if ($ifNoneMatch && $ifNoneMatch === $etag) {
26434c746fbSgerardnico                /**
26534c746fbSgerardnico                 * Don't add a body
26634c746fbSgerardnico                 */
26704fd306cSNickeau                $excutingContext
26804fd306cSNickeau                    ->response()
26904fd306cSNickeau                    ->setStatus(HttpResponseStatus::NOT_MODIFIED)
270c3437056SNickeau                    ->setEvent($event)
271c3437056SNickeau                    ->setCanonical(self::CANONICAL)
27204fd306cSNickeau                    ->end();
273c3437056SNickeau                return;
274c3437056SNickeau            }
275c3437056SNickeau        }
276c3437056SNickeau
277c3437056SNickeau
278c3437056SNickeau        /**
279c3437056SNickeau         * Download or display feature
280c3437056SNickeau         * (Taken over from SendFile)
281c3437056SNickeau         */
28204fd306cSNickeau        try {
28304fd306cSNickeau            $mime = FileSystems::getMime($mediaToSend);
28404fd306cSNickeau        } catch (ExceptionNotFound $e) {
28504fd306cSNickeau            $excutingContext->response()
28604fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
28704fd306cSNickeau                ->setEvent($event)
28804fd306cSNickeau                ->setCanonical(self::CANONICAL)
28904fd306cSNickeau                ->setBodyAsJsonMessage("Mime not found")
29004fd306cSNickeau                ->end();
29104fd306cSNickeau            return;
29204fd306cSNickeau        }
293c3437056SNickeau        $download = $event->data["download"];
294c3437056SNickeau        if ($download && $mime->toString() !== "image/svg+xml") {
295c3437056SNickeau            header('Content-Disposition: attachment;' . rfc2231_encode(
296c3437056SNickeau                    'filename', PhpString::basename($originalFile)) . ';'
297c3437056SNickeau            );
298c3437056SNickeau        } else {
299c3437056SNickeau            header('Content-Disposition: inline;' . rfc2231_encode(
300c3437056SNickeau                    'filename', PhpString::basename($originalFile)) . ';'
301c3437056SNickeau            );
302c3437056SNickeau        }
303c3437056SNickeau
304c3437056SNickeau        /**
305c3437056SNickeau         * The vary header avoid caching
306c3437056SNickeau         * Delete it
307c3437056SNickeau         */
308c3437056SNickeau        action_plugin_combo_cache::deleteVaryHeader();
309c3437056SNickeau
310c3437056SNickeau        /**
311c3437056SNickeau         * Use x-sendfile header to pass the delivery to compatible web servers
312c3437056SNickeau         * (Taken over from SendFile)
313c3437056SNickeau         */
31404fd306cSNickeau        http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId());
315c3437056SNickeau
316c3437056SNickeau        /**
317c3437056SNickeau         * Send the file
318c3437056SNickeau         */
31904fd306cSNickeau        $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb");
320c3437056SNickeau        if ($filePointer) {
321c3437056SNickeau            http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString());
322c3437056SNickeau            /**
323c3437056SNickeau             * The {@link http_rangeRequest} exit not on test
324c3437056SNickeau             * Trying to stop the dokuwiki processing of {@link sendFile()}
325c3437056SNickeau             * Until {@link HttpResponse} can send resource
326c3437056SNickeau             * TODO: integrate it in {@link HttpResponse}
327c3437056SNickeau             */
328c3437056SNickeau            if (PluginUtility::isDevOrTest()) {
329c3437056SNickeau                /**
330c3437056SNickeau                 * Add test info into the request
331c3437056SNickeau                 */
332c3437056SNickeau                $testRequest = TestRequest::getRunning();
333c3437056SNickeau
334c3437056SNickeau                if ($testRequest !== null) {
335c3437056SNickeau                    $testRequest->addData(HttpResponse::EXIT_KEY, "File Send");
336c3437056SNickeau                }
337c3437056SNickeau                if ($event !== null) {
338c3437056SNickeau                    $event->stopPropagation();
339c3437056SNickeau                    $event->preventDefault();
340c3437056SNickeau                }
341c3437056SNickeau            }
342c3437056SNickeau        } else {
34304fd306cSNickeau            $excutingContext->response()
34404fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
34504fd306cSNickeau                ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?")
34604fd306cSNickeau                ->end();
347c3437056SNickeau        }
348c3437056SNickeau
349c3437056SNickeau    }
350c3437056SNickeau
351c3437056SNickeau    /**
352c3437056SNickeau     * @param Path $mediaFile
353c3437056SNickeau     * @param Array $properties - the query properties
354c3437056SNickeau     * @return string
35504fd306cSNickeau     * @throws ExceptionNotFound
356c3437056SNickeau     */
357c3437056SNickeau    public
358c3437056SNickeau    static function getEtagValue(Path $mediaFile, array $properties): string
359c3437056SNickeau    {
36004fd306cSNickeau        clearstatcache();
361c3437056SNickeau        $etagString = FileSystems::getModifiedTime($mediaFile)->format('r');
362c3437056SNickeau        ksort($properties);
363c3437056SNickeau        foreach ($properties as $key => $value) {
36404fd306cSNickeau
365c3437056SNickeau            /**
366c3437056SNickeau             * Media is already on the URL
367c3437056SNickeau             * tok is just added when w and h are on the url
368c3437056SNickeau             * Buster is the timestamp
369c3437056SNickeau             */
37004fd306cSNickeau            if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) {
371c3437056SNickeau                continue;
372c3437056SNickeau            }
373c3437056SNickeau            /**
374c3437056SNickeau             * If empty means not used
375c3437056SNickeau             */
37604fd306cSNickeau            if (trim($value) === "") {
377c3437056SNickeau                continue;
378c3437056SNickeau            }
379c3437056SNickeau            $etagString .= "$key=$value";
380c3437056SNickeau        }
381c3437056SNickeau        return '"' . md5($etagString) . '"';
382c3437056SNickeau    }
383c3437056SNickeau
384c3437056SNickeau
385c3437056SNickeau}
386