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