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