xref: /plugin/combo/action/staticresource.php (revision 04fd306c7c155fa133ebb3669986875d65988276)
1c3437056SNickeau<?php
2c3437056SNickeau
3*04fd306cSNickeauuse ComboStrap\ExceptionCompile;
4*04fd306cSNickeauuse ComboStrap\ExceptionNotFound;
5*04fd306cSNickeauuse ComboStrap\ExecutionContext;
6*04fd306cSNickeauuse ComboStrap\FetcherRaster;
7c3437056SNickeauuse ComboStrap\FileSystems;
8c3437056SNickeauuse ComboStrap\Http;
9c3437056SNickeauuse ComboStrap\HttpResponse;
10*04fd306cSNickeauuse ComboStrap\HttpResponseStatus;
114cadd4f8SNickeauuse ComboStrap\Identity;
12*04fd306cSNickeauuse ComboStrap\IFetcher;
13c3437056SNickeauuse ComboStrap\LocalPath;
144cadd4f8SNickeauuse ComboStrap\LogUtility;
15*04fd306cSNickeauuse ComboStrap\Mime;
16c3437056SNickeauuse ComboStrap\Path;
17c3437056SNickeauuse ComboStrap\PluginUtility;
18*04fd306cSNickeauuse ComboStrap\SiteConfig;
19*04fd306cSNickeauuse ComboStrap\Web\Url;
20*04fd306cSNickeauuse 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    /**
45*04fd306cSNickeau     * 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";
48*04fd306cSNickeau    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
72*04fd306cSNickeau    /**
73*04fd306cSNickeau     * @param Doku_Event $event
74*04fd306cSNickeau     * https://www.dokuwiki.org/devel:event:fetch_media_status
75*04fd306cSNickeau     */
76c3437056SNickeau    function handleMediaStatus(Doku_Event $event, $params)
77c3437056SNickeau    {
78c3437056SNickeau
79*04fd306cSNickeau        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE];
80*04fd306cSNickeau        $fetcher = $_GET[IFetcher::FETCHER_KEY];
81*04fd306cSNickeau        if ($drive === null && $fetcher === null) {
82c3437056SNickeau            return;
83c3437056SNickeau        }
84*04fd306cSNickeau        if ($fetcher === FetcherRaster::CANONICAL) {
85*04fd306cSNickeau            // not yet implemented
86c3437056SNickeau            return;
87c3437056SNickeau        }
88*04fd306cSNickeau
89*04fd306cSNickeau        /**
90*04fd306cSNickeau         * Security
91*04fd306cSNickeau         */
92*04fd306cSNickeau        if ($drive === WikiPath::CACHE_DRIVE) {
934cadd4f8SNickeau            $event->data['download'] = false;
944cadd4f8SNickeau            if (!Identity::isManager()) {
95*04fd306cSNickeau                $event->data['status'] = HttpResponseStatus::NOT_AUTHORIZED;
96*04fd306cSNickeau                return;
974cadd4f8SNickeau            }
984cadd4f8SNickeau        }
99c3437056SNickeau
100*04fd306cSNickeau
101*04fd306cSNickeau        /**
102*04fd306cSNickeau         * Add the extra attributes
103*04fd306cSNickeau         */
104*04fd306cSNickeau        $fetchUrl = Url::createFromGetOrPostGlobalVariable();
105*04fd306cSNickeau        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
106*04fd306cSNickeau        try {
107*04fd306cSNickeau
108*04fd306cSNickeau            $fetcher = $executionContext->createPathMainFetcherFromUrl($fetchUrl);
109*04fd306cSNickeau            $fetchPath = $fetcher->getFetchPath();
110*04fd306cSNickeau            $event->data['file'] = $fetchPath->toAbsoluteId();
111*04fd306cSNickeau            $event->data['status'] = HttpResponseStatus::ALL_GOOD;
112*04fd306cSNickeau            $mime = $fetcher->getMime();
113*04fd306cSNickeau            $event->data["mime"] = $mime->toString();
114*04fd306cSNickeau            /**
115*04fd306cSNickeau             * TODO: set download as parameter of the fetch url
116*04fd306cSNickeau             */
117*04fd306cSNickeau            if ($mime->isImage() || in_array($mime->toString(), [Mime::JAVASCRIPT, Mime::CSS])) {
118*04fd306cSNickeau                $event->data['download'] = false;
119*04fd306cSNickeau            } else {
120*04fd306cSNickeau                $event->data['download'] = true;
121*04fd306cSNickeau            }
122*04fd306cSNickeau            $event->data['statusmessage'] = '';
123*04fd306cSNickeau        } catch (\Exception $e) {
124*04fd306cSNickeau
125*04fd306cSNickeau            $executionContext
126*04fd306cSNickeau                ->response()
127*04fd306cSNickeau                ->setException($e)
128*04fd306cSNickeau                ->setStatusAndBodyFromException($e)
129*04fd306cSNickeau                ->end();
130*04fd306cSNickeau
131*04fd306cSNickeau            //$event->data['file'] = WikiPath::createComboResource("images:error-bad-format.svg")->toLocalPath()->toAbsolutePath()->toQualifiedId();
132*04fd306cSNickeau            $event->data['file'] = "error.json";
133*04fd306cSNickeau            $event->data['statusmessage'] = $e->getMessage();
134*04fd306cSNickeau            //$event->data['status'] = $httpResponse->getStatus();
135*04fd306cSNickeau            $event->data['mime'] = Mime::JSON;
136*04fd306cSNickeau
137*04fd306cSNickeau
138*04fd306cSNickeau        }
139*04fd306cSNickeau
140c3437056SNickeau    }
141c3437056SNickeau
142c3437056SNickeau    function handleSendFile(Doku_Event $event, $params)
143c3437056SNickeau    {
144c3437056SNickeau
145*04fd306cSNickeau        if (ExecutionContext::getActualOrCreateFromEnv()->response()->hasEnded()) {
146*04fd306cSNickeau            // when there is an error for instance
147*04fd306cSNickeau            return;
148*04fd306cSNickeau        }
149c3437056SNickeau        /**
1504cadd4f8SNickeau         * If there is no buster key, the infinite cache is off
151c3437056SNickeau         */
152*04fd306cSNickeau        $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
161*04fd306cSNickeau        $physicalFile = $event->data["file"]; // the file modified or the file to send
1624cadd4f8SNickeau        if (empty($physicalFile)) {
1634cadd4f8SNickeau            $physicalFile = $originalFile;
1644cadd4f8SNickeau        }
165*04fd306cSNickeau        $mediaToSend = LocalPath::createFromPathString($physicalFile);
1664cadd4f8SNickeau        if (!FileSystems::exists($mediaToSend)) {
167*04fd306cSNickeau            if (PluginUtility::isDevOrTest()) {
168*04fd306cSNickeau                LogUtility::internalError("The media ($mediaToSend) does not exist", self::CANONICAL);
169*04fd306cSNickeau            }
1704cadd4f8SNickeau            return;
1714cadd4f8SNickeau        }
172c3437056SNickeau
173c3437056SNickeau        /**
174c3437056SNickeau         * Combo Media
175c3437056SNickeau         * (Static file from the combo resources are always taken over)
176c3437056SNickeau         */
177*04fd306cSNickeau        $drive = $_GET[WikiPath::DRIVE_ATTRIBUTE];
1784cadd4f8SNickeau        if ($drive === null) {
179c3437056SNickeau
180*04fd306cSNickeau            $confValue = SiteConfig::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1);
1814cadd4f8SNickeau            if (!$confValue) {
182c3437056SNickeau                return;
183c3437056SNickeau            }
184c3437056SNickeau
1854cadd4f8SNickeau            try {
186*04fd306cSNickeau                $dokuPath = $mediaToSend->toWikiPath();
187*04fd306cSNickeau            } 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"];
218*04fd306cSNickeau        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
223*04fd306cSNickeau                $cacheControlDirective[] = self::NO_TRANSFORM;
224*04fd306cSNickeau            }
225*04fd306cSNickeau        } catch (ExceptionNotFound $e) {
226*04fd306cSNickeau            LogUtility::warning("The media ($mediaToSend) does not have any extension.");
227c3437056SNickeau        }
228c3437056SNickeau        header("Cache-Control: " . implode(", ", $cacheControlDirective));
229c3437056SNickeau        Http::removeHeaderIfPresent("Pragma");
230c3437056SNickeau
231*04fd306cSNickeau        $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         */
242*04fd306cSNickeau        try {
243c3437056SNickeau            $etag = self::getEtagValue($mediaToSend, $_REQUEST);
244c3437056SNickeau            header("ETag: $etag");
245*04fd306cSNickeau        } catch (ExceptionNotFound $e) {
246*04fd306cSNickeau            // internal error
247*04fd306cSNickeau            $excutingContext->response()
248*04fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
249*04fd306cSNickeau                ->setEvent($event)
250*04fd306cSNickeau                ->setCanonical(self::CANONICAL)
251*04fd306cSNickeau                ->setBodyAsJsonMessage("We were unable to get the etag because the media was not found. Error: {$e->getMessage()}")
252*04fd306cSNickeau                ->end();
253*04fd306cSNickeau            return;
254*04fd306cSNickeau        }
255*04fd306cSNickeau
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) {
264*04fd306cSNickeau                $excutingContext
265*04fd306cSNickeau                    ->response()
266*04fd306cSNickeau                    ->setStatus(HttpResponseStatus::NOT_MODIFIED)
267c3437056SNickeau                    ->setEvent($event)
268c3437056SNickeau                    ->setCanonical(self::CANONICAL)
269*04fd306cSNickeau                    ->setBodyAsJsonMessage("File not modified")
270*04fd306cSNickeau                    ->end();
271c3437056SNickeau                return;
272c3437056SNickeau            }
273c3437056SNickeau        }
274c3437056SNickeau
275c3437056SNickeau
276c3437056SNickeau        /**
277c3437056SNickeau         * Download or display feature
278c3437056SNickeau         * (Taken over from SendFile)
279c3437056SNickeau         */
280*04fd306cSNickeau        try {
281*04fd306cSNickeau            $mime = FileSystems::getMime($mediaToSend);
282*04fd306cSNickeau        } catch (ExceptionNotFound $e) {
283*04fd306cSNickeau            $excutingContext->response()
284*04fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
285*04fd306cSNickeau                ->setEvent($event)
286*04fd306cSNickeau                ->setCanonical(self::CANONICAL)
287*04fd306cSNickeau                ->setBodyAsJsonMessage("Mime not found")
288*04fd306cSNickeau                ->end();
289*04fd306cSNickeau            return;
290*04fd306cSNickeau        }
291c3437056SNickeau        $download = $event->data["download"];
292c3437056SNickeau        if ($download && $mime->toString() !== "image/svg+xml") {
293c3437056SNickeau            header('Content-Disposition: attachment;' . rfc2231_encode(
294c3437056SNickeau                    'filename', PhpString::basename($originalFile)) . ';'
295c3437056SNickeau            );
296c3437056SNickeau        } else {
297c3437056SNickeau            header('Content-Disposition: inline;' . rfc2231_encode(
298c3437056SNickeau                    'filename', PhpString::basename($originalFile)) . ';'
299c3437056SNickeau            );
300c3437056SNickeau        }
301c3437056SNickeau
302c3437056SNickeau        /**
303c3437056SNickeau         * The vary header avoid caching
304c3437056SNickeau         * Delete it
305c3437056SNickeau         */
306c3437056SNickeau        action_plugin_combo_cache::deleteVaryHeader();
307c3437056SNickeau
308c3437056SNickeau        /**
309c3437056SNickeau         * Use x-sendfile header to pass the delivery to compatible web servers
310c3437056SNickeau         * (Taken over from SendFile)
311c3437056SNickeau         */
312*04fd306cSNickeau        http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId());
313c3437056SNickeau
314c3437056SNickeau        /**
315c3437056SNickeau         * Send the file
316c3437056SNickeau         */
317*04fd306cSNickeau        $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb");
318c3437056SNickeau        if ($filePointer) {
319c3437056SNickeau            http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString());
320c3437056SNickeau            /**
321c3437056SNickeau             * The {@link http_rangeRequest} exit not on test
322c3437056SNickeau             * Trying to stop the dokuwiki processing of {@link sendFile()}
323c3437056SNickeau             * Until {@link HttpResponse} can send resource
324c3437056SNickeau             * TODO: integrate it in {@link HttpResponse}
325c3437056SNickeau             */
326c3437056SNickeau            if (PluginUtility::isDevOrTest()) {
327c3437056SNickeau                /**
328c3437056SNickeau                 * Add test info into the request
329c3437056SNickeau                 */
330c3437056SNickeau                $testRequest = TestRequest::getRunning();
331c3437056SNickeau
332c3437056SNickeau                if ($testRequest !== null) {
333c3437056SNickeau                    $testRequest->addData(HttpResponse::EXIT_KEY, "File Send");
334c3437056SNickeau                }
335c3437056SNickeau                if ($event !== null) {
336c3437056SNickeau                    $event->stopPropagation();
337c3437056SNickeau                    $event->preventDefault();
338c3437056SNickeau                }
339c3437056SNickeau            }
340c3437056SNickeau        } else {
341*04fd306cSNickeau            $excutingContext->response()
342*04fd306cSNickeau                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
343*04fd306cSNickeau                ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?")
344*04fd306cSNickeau                ->end();
345c3437056SNickeau        }
346c3437056SNickeau
347c3437056SNickeau    }
348c3437056SNickeau
349c3437056SNickeau    /**
350c3437056SNickeau     * @param Path $mediaFile
351c3437056SNickeau     * @param Array $properties - the query properties
352c3437056SNickeau     * @return string
353*04fd306cSNickeau     * @throws ExceptionNotFound
354c3437056SNickeau     */
355c3437056SNickeau    public
356c3437056SNickeau    static function getEtagValue(Path $mediaFile, array $properties): string
357c3437056SNickeau    {
358*04fd306cSNickeau        clearstatcache();
359c3437056SNickeau        $etagString = FileSystems::getModifiedTime($mediaFile)->format('r');
360c3437056SNickeau        ksort($properties);
361c3437056SNickeau        foreach ($properties as $key => $value) {
362*04fd306cSNickeau
363c3437056SNickeau            /**
364c3437056SNickeau             * Media is already on the URL
365c3437056SNickeau             * tok is just added when w and h are on the url
366c3437056SNickeau             * Buster is the timestamp
367c3437056SNickeau             */
368*04fd306cSNickeau            if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) {
369c3437056SNickeau                continue;
370c3437056SNickeau            }
371c3437056SNickeau            /**
372c3437056SNickeau             * If empty means not used
373c3437056SNickeau             */
374*04fd306cSNickeau            if (trim($value) === "") {
375c3437056SNickeau                continue;
376c3437056SNickeau            }
377c3437056SNickeau            $etagString .= "$key=$value";
378c3437056SNickeau        }
379c3437056SNickeau        return '"' . md5($etagString) . '"';
380c3437056SNickeau    }
381c3437056SNickeau
382c3437056SNickeau
383c3437056SNickeau}
384