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