xref: /plugin/combo/action/staticresource.php (revision c3437056399326d621a01da73b649707fbb0ae69)
1<?php
2
3use ComboStrap\CacheMedia;
4use ComboStrap\DokuPath;
5use ComboStrap\FileSystems;
6use ComboStrap\Http;
7use ComboStrap\HttpResponse;
8use ComboStrap\LocalPath;
9use ComboStrap\Path;
10use ComboStrap\PluginUtility;
11use dokuwiki\Utf8\PhpString;
12
13require_once(__DIR__ . '/../ComboStrap/PluginUtility.php');
14
15/**
16 * Modify the serving of static resource via fetch.php
17 */
18class action_plugin_combo_staticresource extends DokuWiki_Action_Plugin
19{
20
21
22    /**
23     * https://www.ietf.org/rfc/rfc2616.txt
24     * To mark a response as "never expires," an origin server sends an Expires date approximately one year
25     * from the time the response is sent.
26     * HTTP/1.1 servers SHOULD NOT send Expires dates more than one year in the future.
27     *
28     * In seconds = 365*24*60*60
29     */
30    const INFINITE_MAX_AGE = 31536000;
31
32    const CANONICAL = "cache";
33
34    /**
35     * Enable an infinite cache on static resources (image, script, ...) with a {@link CacheMedia::CACHE_BUSTER_KEY}
36     */
37    public const CONF_STATIC_CACHE_ENABLED = "staticCacheEnabled";
38
39
40    /**
41     * @param Doku_Event_Handler $controller
42     */
43    function register(Doku_Event_Handler $controller)
44    {
45
46        /**
47         * Redirect the combo resources to the good file path
48         * https://www.dokuwiki.org/devel:event:fetch_media_status
49         */
50        $controller->register_hook('FETCH_MEDIA_STATUS', 'BEFORE', $this, 'handleMediaStatus', array());
51
52        /**
53         * Serve the image and static resources with HTTP cache control
54         * https://www.dokuwiki.org/devel:event:media_sendfile
55         */
56        $controller->register_hook('MEDIA_SENDFILE', 'BEFORE', $this, 'handleSendFile', array());
57
58
59    }
60
61
62    function handleMediaStatus(Doku_Event $event, $params)
63    {
64
65        if (!isset($_GET[DokuPath::WIKI_FS_TYPE])) {
66            return;
67        }
68        $type = $_GET[DokuPath::WIKI_FS_TYPE];
69        if ($type !== DokuPath::RESOURCE_TYPE) {
70            // The other resources have ACL
71            // and this endpoint is normally only for
72            $event->data['status'] = HttpResponse::STATUS_NOT_AUTHORIZED;
73            return;
74        }
75        $mediaId = $event->data['media'];
76        $mediaPath = DokuPath::createDokuPath($mediaId, $type);
77        $event->data['file'] = $mediaPath->toLocalPath()->toAbsolutePath()->toString();
78        if (FileSystems::exists($mediaPath)) {
79            $event->data['status'] = HttpResponse::STATUS_ALL_GOOD;
80            $event->data['statusmessage'] = '';
81            $event->data['mime'] = $mediaPath->getMime();
82        }
83
84    }
85
86    function handleSendFile(Doku_Event $event, $params)
87    {
88
89        $mediaId = $event->data["media"];
90
91        /**
92         * Do we send this file
93         */
94        $isStaticFileManaged = false;
95
96        /**
97         * Combo Media
98         * (Static file from the combo resources are always taken over)
99         */
100        if (isset($_GET[DokuPath::WIKI_FS_TYPE])) {
101
102            $isStaticFileManaged = $_GET[DokuPath::WIKI_FS_TYPE] === DokuPath::RESOURCE_TYPE;
103
104        }
105
106        /**
107         * DokuWiki Resource media
108         */
109        if (!$isStaticFileManaged) {
110
111            /**
112             * If there is the buster key, the infinite cache is on
113             */
114            if (isset($_GET[CacheMedia::CACHE_BUSTER_KEY])) {
115
116                /**
117                 * To avoid buggy code, we check that the value is not empty
118                 */
119                $cacheKey = $_GET[CacheMedia::CACHE_BUSTER_KEY];
120                if (!empty($cacheKey)) {
121
122                    if (PluginUtility::getConfValue(self::CONF_STATIC_CACHE_ENABLED, 1)) {
123
124                        $dokuPath = DokuPath::createMediaPathFromId($mediaId);
125                        if ($dokuPath->isPublic()) {
126                            /**
127                             * Only for public media
128                             */
129                            $isStaticFileManaged = true;
130                        }
131
132                    }
133                }
134            }
135        }
136
137        if (!$isStaticFileManaged) {
138            return;
139        }
140
141        /**
142         * We take over the complete {@link sendFile()} function and exit
143         *
144         * in {@link sendFile()}, DokuWiki set the `Cache-Control` and
145         * may exit early / send a 304 (not modified) with the function {@link http_conditionalRequest()}
146         * Meaning that the AFTER event is never reached
147         * that we can't send a cache control as below
148         * header("Cache-Control: public, max-age=$infiniteMaxAge, s-maxage=$infiniteMaxAge");
149         *
150         * We take the control over then
151         */
152
153        /**
154         * Send the file
155         */
156        $originalFile = $event->data["orig"]; // the original file
157        $physicalFile = $event->data["file"]; // the file modified
158        if (empty($physicalFile)) {
159            $physicalFile = $originalFile;
160        }
161        $mediaToSend = LocalPath::createFromPath($physicalFile);
162
163
164        /**
165         * The cache instructions
166         */
167        $infiniteMaxAge = self::INFINITE_MAX_AGE;
168        $expires = time() + $infiniteMaxAge;
169        header('Expires: ' . gmdate("D, d M Y H:i:s", $expires) . ' GMT');
170        $cacheControlDirective = ["public", "max-age=$infiniteMaxAge", "immutable"];
171        if ($mediaToSend->getExtension() === "js") {
172            // if a SRI is given and that a proxy is
173            // reducing javascript, it will not match
174            // no-transform will avoid that
175            $cacheControlDirective[] = "no-transform";
176        }
177        header("Cache-Control: " . implode(", ", $cacheControlDirective));
178        Http::removeHeaderIfPresent("Pragma");
179
180        /**
181         * The Etag cache validator
182         *
183         * Dokuwiki {@link http_conditionalRequest()} uses only the datetime of
184         * the file but we need to add the parameters also because they
185         * are generated image
186         *
187         * Last-Modified is not needed for the same reason
188         *
189         */
190        $etag = self::getEtagValue($mediaToSend, $_REQUEST);
191        header("ETag: $etag");
192
193        /**
194         * Conditional Request ?
195         * We don't check on HTTP_IF_MODIFIED_SINCE because this is useless
196         */
197        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
198            $ifNoneMatch = stripslashes($_SERVER['HTTP_IF_NONE_MATCH']);
199            if ($ifNoneMatch && $ifNoneMatch === $etag) {
200
201                HttpResponse::create(HttpResponse::STATUS_NOT_MODIFIED)
202                    ->setEvent($event)
203                    ->setCanonical(self::CANONICAL)
204                    ->sendMessage("File not modified");
205                return;
206            }
207        }
208
209
210        /**
211         * Download or display feature
212         * (Taken over from SendFile)
213         */
214        $mime = $mediaToSend->getMime();
215        $download = $event->data["download"];
216        if ($download && $mime->toString() !== "image/svg+xml") {
217            header('Content-Disposition: attachment;' . rfc2231_encode(
218                    'filename', PhpString::basename($originalFile)) . ';'
219            );
220        } else {
221            header('Content-Disposition: inline;' . rfc2231_encode(
222                    'filename', PhpString::basename($originalFile)) . ';'
223            );
224        }
225
226        /**
227         * The vary header avoid caching
228         * Delete it
229         */
230        action_plugin_combo_cache::deleteVaryHeader();
231
232        /**
233         * Use x-sendfile header to pass the delivery to compatible web servers
234         * (Taken over from SendFile)
235         */
236        http_sendfile($mediaToSend->toAbsolutePath()->toString());
237
238        /**
239         * Send the file
240         */
241        $filePointer = @fopen($mediaToSend->toAbsolutePath()->toString(), "rb");
242        if ($filePointer) {
243            http_rangeRequest($filePointer, FileSystems::getSize($mediaToSend), $mime->toString());
244            /**
245             * The {@link http_rangeRequest} exit not on test
246             * Trying to stop the dokuwiki processing of {@link sendFile()}
247             * Until {@link HttpResponse} can send resource
248             * TODO: integrate it in {@link HttpResponse}
249             */
250            if(PluginUtility::isDevOrTest()) {
251                /**
252                 * Add test info into the request
253                 */
254                $testRequest = TestRequest::getRunning();
255
256                if ($testRequest !== null) {
257                    $testRequest->addData(HttpResponse::EXIT_KEY, "File Send");
258                }
259                if ($event !== null) {
260                    $event->stopPropagation();
261                    $event->preventDefault();
262                }
263            }
264        } else {
265            HttpResponse::create(HttpResponse::STATUS_INTERNAL_ERROR)
266                ->sendMessage("Could not read $mediaToSend - bad permissions?");
267        }
268
269    }
270
271    /**
272     * @param Path $mediaFile
273     * @param Array $properties - the query properties
274     * @return string
275     */
276    public
277    static function getEtagValue(Path $mediaFile, array $properties): string
278    {
279        $etagString = FileSystems::getModifiedTime($mediaFile)->format('r');
280        ksort($properties);
281        foreach ($properties as $key => $value) {
282            /**
283             * Media is already on the URL
284             * tok is just added when w and h are on the url
285             * Buster is the timestamp
286             */
287            if (in_array($key, ["media", "tok", CacheMedia::CACHE_BUSTER_KEY])) {
288                continue;
289            }
290            /**
291             * If empty means not used
292             */
293            if (empty($value)) {
294                continue;
295            }
296            $etagString .= "$key=$value";
297        }
298        return '"' . md5($etagString) . '"';
299    }
300
301
302}
303