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] ?? null;
80        $fetcher = $_GET[IFetcher::FETCHER_KEY] ?? null;
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] ?? null;
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                /**
265                 * Don't add a body
266                 */
267                $excutingContext
268                    ->response()
269                    ->setStatus(HttpResponseStatus::NOT_MODIFIED)
270                    ->setEvent($event)
271                    ->setCanonical(self::CANONICAL)
272                    ->end();
273                return;
274            }
275        }
276
277
278        /**
279         * Download or display feature
280         * (Taken over from SendFile)
281         */
282        try {
283            $mime = FileSystems::getMime($mediaToSend);
284        } catch (ExceptionNotFound $e) {
285            $excutingContext->response()
286                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
287                ->setEvent($event)
288                ->setCanonical(self::CANONICAL)
289                ->setBodyAsJsonMessage("Mime not found")
290                ->end();
291            return;
292        }
293        $download = $event->data["download"];
294        if ($download && $mime->toString() !== "image/svg+xml") {
295            header('Content-Disposition: attachment;' . rfc2231_encode(
296                    'filename', PhpString::basename($originalFile)) . ';'
297            );
298        } else {
299            header('Content-Disposition: inline;' . rfc2231_encode(
300                    'filename', PhpString::basename($originalFile)) . ';'
301            );
302        }
303
304        /**
305         * The vary header avoid caching
306         * Delete it
307         */
308        action_plugin_combo_cache::deleteVaryHeader();
309
310        /**
311         * Use x-sendfile header to pass the delivery to compatible web servers
312         * (Taken over from SendFile)
313         */
314        http_sendfile($mediaToSend->toAbsolutePath()->toAbsoluteId());
315
316        /**
317         * Send the file
318         */
319        $filePointer = @fopen($mediaToSend->toAbsolutePath()->toAbsoluteId(), "rb");
320        if ($filePointer) {
321            http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString());
322            /**
323             * The {@link http_rangeRequest} exit not on test
324             * Trying to stop the dokuwiki processing of {@link sendFile()}
325             * Until {@link HttpResponse} can send resource
326             * TODO: integrate it in {@link HttpResponse}
327             */
328            if (PluginUtility::isDevOrTest()) {
329                /**
330                 * Add test info into the request
331                 */
332                $testRequest = TestRequest::getRunning();
333
334                if ($testRequest !== null) {
335                    $testRequest->addData(HttpResponse::EXIT_KEY, "File Send");
336                }
337                if ($event !== null) {
338                    $event->stopPropagation();
339                    $event->preventDefault();
340                }
341            }
342        } else {
343            $excutingContext->response()
344                ->setStatus(HttpResponseStatus::INTERNAL_ERROR)
345                ->setBodyAsJsonMessage("Could not read $mediaToSend - bad permissions?")
346                ->end();
347        }
348
349    }
350
351    /**
352     * @param Path $mediaFile
353     * @param Array $properties - the query properties
354     * @return string
355     * @throws ExceptionNotFound
356     */
357    public
358    static function getEtagValue(Path $mediaFile, array $properties): string
359    {
360        clearstatcache();
361        $etagString = FileSystems::getModifiedTime($mediaFile)->format('r');
362        ksort($properties);
363        foreach ($properties as $key => $value) {
364
365            /**
366             * Media is already on the URL
367             * tok is just added when w and h are on the url
368             * Buster is the timestamp
369             */
370            if (in_array($key, ["media", "tok", IFetcher::CACHE_BUSTER_KEY])) {
371                continue;
372            }
373            /**
374             * If empty means not used
375             */
376            if (trim($value) === "") {
377                continue;
378            }
379            $etagString .= "$key=$value";
380        }
381        return '"' . md5($etagString) . '"';
382    }
383
384
385}
386