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