Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Tiny text editor integration - TinyMCE Loader.
19
 *
20
 * @package    editor_tiny
21
 * @copyright  2022 Andrew Lyons <andrew@nicols.co.uk>
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
namespace editor_tiny;
26
 
27
// Disable moodle specific debug messages and any errors in output.
28
define('NO_DEBUG_DISPLAY', true);
29
 
30
// We need just the values from config.php and minlib.php.
31
define('ABORT_AFTER_CONFIG', true);
32
 
33
// This stops immediately at the beginning of lib/setup.php.
34
require('../../../config.php');
35
 
36
/**
37
 * An anonymous class to handle loading and serving TinyMCE JavaScript.
38
 *
39
 * @copyright  2021 Andrew Lyons <andrew@nicols.co.uk>
40
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41
 */
42
class loader {
43
    /** @var string The filepath requested */
44
    protected $filepath;
45
 
46
    /** @var int The revision requested */
47
    protected $rev;
48
 
49
    /** @var string The mimetype to send */
50
    protected $mimetype = null;
51
 
52
    /** @var string The component to use */
53
    protected $component;
54
 
55
    /** @var string The complete path to the candidate file */
56
    protected $candidatefile;
57
 
58
    /**
59
     * Initialise the class, parse the request and serve the content.
60
     */
61
    public function __construct() {
62
        $this->parse_file_information_from_url();
63
        $this->serve_file();
64
    }
65
 
66
    /**
67
     * Parse the file information from the URL.
68
     */
69
    protected function parse_file_information_from_url(): void {
70
        global $CFG;
71
 
72
        // The URL format is /[revision]/[filepath].
73
        // The revision is an integer with negative values meaning the file is not cached.
74
        // The filepath is a child of the TinyMCE js/tinymce directory containing all upstream code.
75
        // The filepath is cleaned using the SAFEPATH option, which does not allow directory traversal.
76
        if ($slashargument = min_get_slash_argument()) {
77
            $slashargument = ltrim($slashargument, '/');
78
            if (substr_count($slashargument, '/') < 1) {
79
                $this->send_not_found();
80
            }
81
 
82
            [$rev, $filepath] = explode('/', $slashargument, 2);
83
            $this->rev  = min_clean_param($rev, 'INT');
84
            $this->filepath = min_clean_param($filepath, 'SAFEPATH');
85
        } else {
86
            $this->rev  = min_optional_param('rev', 0, 'INT');
87
            $this->filepath = min_optional_param('filepath', 'standard', 'SAFEPATH');
88
        }
89
 
90
        $extension = pathinfo($this->filepath, PATHINFO_EXTENSION);
91
        if ($extension === 'css') {
92
            $this->mimetype = 'text/css';
93
        } else if ($extension === 'js') {
94
            $this->mimetype = 'application/javascript';
95
        } else if ($extension === 'map') {
96
            $this->mimetype = 'application/json';
97
        } else {
98
            $this->send_not_found();
99
        }
100
 
101
        $filepathhash = sha1("{$this->filepath}");
102
        if (preg_match('/^plugins\/tiny_/', $this->filepath)) {
103
            $parts = explode('/', $this->filepath);
104
            array_shift($parts);
105
            $component = array_shift($parts);
106
            $this->component = preg_replace('/^tiny_/', '', $component);
107
            $this->filepath = implode('/', $parts);
108
        }
109
        $this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/{$filepathhash}";
110
    }
111
 
112
    /**
113
     * Serve the requested file from the most appropriate location, caching if possible.
114
     */
115
    public function serve_file(): void {
116
        // Attempt to send the cached filepathpack.
117
        // We only cache the file if the rev is valid.
118
        if (min_is_revision_valid_and_current($this->rev)) {
119
            if ($this->is_candidate_file_available()) {
120
                // The send_cached_file_if_available function will exit if successful.
121
                // In theory the file could become unavailable after checking that the file exists.
122
                // Whilst this is unlikely, fall back to caching the content below.
123
                $this->send_cached_file_if_available();
124
            }
125
 
126
            // The file isn't cached yet.
127
            // Store it in the cache and serve it.
128
            $this->store_filepath_file();
129
            $this->send_cached();
130
        } else {
131
            // If the revision is less than 0, then do not cache anything.
132
            // Moodle is configured to not cache javascript or css.
133
            $this->send_uncached_from_dirroot();
134
        }
135
    }
136
 
137
    /**
138
     * Get the full filepath to the requested file.
139
     *
140
     * @return string
141
     */
142
    protected function get_filepath_from_dirroot(): ?string {
143
        global $CFG;
144
 
145
        $rootdir = "{$CFG->dirroot}/lib/editor/tiny";
146
        if ($this->component) {
147
            $rootdir .= "/plugins/{$this->component}/js";
148
        } else {
149
            $rootdir .= "/js/tinymce";
150
        }
151
 
152
        $filepath = "{$rootdir}/{$this->filepath}";
153
        if (file_exists($filepath)) {
154
            return $filepath;
155
        }
156
 
157
        return null;
158
    }
159
 
160
    /**
161
     * Load the file content from the dirroot.
162
     *
163
     * @return string
164
     */
165
    protected function load_content_from_dirroot(): ?string {
166
        if ($filepath = $this->get_filepath_from_dirroot()) {
167
            return file_get_contents($filepath);
168
        }
169
 
170
        return null;
171
    }
172
 
173
    /**
174
     * Send the file content from the dirroot.
175
     *
176
     * If the file is not found, send the 404 response instead.
177
     */
178
    protected function send_uncached_from_dirroot(): void {
179
        if ($filepath = $this->get_filepath_from_dirroot()) {
180
            $this->send_uncached_file($filepath);
181
        }
182
 
183
        $this->send_not_found();
184
    }
185
 
186
    /**
187
     * Check whether the candidate file exists.
188
     *
189
     * @return bool
190
     */
191
    protected function is_candidate_file_available(): bool {
192
        return file_exists($this->candidatefile);
193
    }
194
 
195
    /**
196
     * Send the candidate file.
197
     */
198
    protected function send_cached_file_if_available(): void {
199
        global $_SERVER;
200
 
201
        if (file_exists($this->candidatefile)) {
202
            // The candidate file exists so will be sent regardless.
203
 
204
            if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
205
                // The browser sent headers to check if the file has changed.
206
                // We do not actually need to verify the eTag value or compare modification headers because our files
207
                // never change in cache. When changes are made we increment the revision counter.
208
                $this->send_unmodified_headers(filemtime($this->candidatefile));
209
            }
210
 
211
            // No modification headers were sent so simply serve the file from cache.
212
            $this->send_cached($this->candidatefile);
213
        }
214
    }
215
 
216
    /**
217
     * Store the file content in the candidate file.
218
     */
219
    protected function store_filepath_file(): void {
220
        global $CFG;
221
 
222
        clearstatcache();
223
        if (!file_exists(dirname($this->candidatefile))) {
224
            @mkdir(dirname($this->candidatefile), $CFG->directorypermissions, true);
225
        }
226
 
227
        // Prevent serving of incomplete file from concurrent request,
228
        // the rename() should be more atomic than fwrite().
229
        ignore_user_abort(true);
230
 
231
        $filename = $this->candidatefile;
232
        if ($fp = fopen($filename . '.tmp', 'xb')) {
233
            $content = $this->load_content_from_dirroot();
234
            fwrite($fp, $content);
235
            fclose($fp);
236
            rename($filename . '.tmp', $filename);
237
            @chmod($filename, $CFG->filepermissions);
238
            @unlink($filename . '.tmp'); // Just in case anything fails.
239
        }
240
 
241
        ignore_user_abort(false);
242
        if (connection_aborted()) {
243
            die;
244
        }
245
    }
246
 
247
    /**
248
     * Get the eTag for the candidate file.
249
     *
250
     * This is a unique hash based on the file arguments.
251
     * It does not need to consider the file content because we use a cache busting URL.
252
     *
253
     * @return string The eTag content
254
     */
255
    protected function get_etag(): string {
256
        $etag = [
257
            $this->filepath,
258
            $this->rev,
259
        ];
260
 
261
        return sha1(implode('/', $etag));
262
    }
263
 
264
    /**
265
     * Send the candidate file, with aggressive cachign headers.
266
     *
267
     * This includdes eTags, a last-modified, and expiry approximately 90 days in the future.
268
     */
269
    protected function send_cached(): void {
270
        $path = $this->candidatefile;
271
 
272
        // 90 days only - based on Moodle point release cadence being every 3 months.
273
        $lifetime = 60 * 60 * 24 * 90;
274
 
275
        header('Etag: "' . $this->get_etag() . '"');
276
        header('Content-Disposition: inline; filename="filepath.php"');
277
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT');
278
        header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
279
        header('Pragma: ');
280
        header('Cache-Control: public, max-age=' . $lifetime . ', immutable');
281
        header('Accept-Ranges: none');
282
        header("Content-Type: {$this->mimetype}; charset=utf-8");
283
        if (!min_enable_zlib_compression()) {
284
            header('Content-Length: ' . filesize($path));
285
        }
286
 
287
        readfile($path);
288
        die;
289
    }
290
 
291
    /**
292
     * Sends the content directly without caching it.
293
     *
294
     * No aggressive caching is used, and the expiry is set to the current time.
295
     *
296
     * @param string $filepath
297
     */
298
    protected function send_uncached_file(string $filepath): void {
299
        header('Content-Disposition: inline; filename="styles_debug.php"');
300
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
301
        header('Expires: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
302
        header('Pragma: ');
303
        header('Accept-Ranges: none');
304
        header("Content-Type: {$this->mimetype}; charset=utf-8");
305
 
306
        readfile($filepath);
307
        die;
308
    }
309
 
310
    /**
311
     * Send headers to indicate that the file has not been modified at all
312
     *
313
     * @param int $lastmodified
314
     */
315
    protected function send_unmodified_headers(int $lastmodified): void {
316
        // 90 days only - based on Moodle point release cadence being every 3 months.
317
        $lifetime = 60 * 60 * 24 * 90;
318
        header('HTTP/1.1 304 Not Modified');
319
        header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
320
        header('Cache-Control: public, max-age=' . $lifetime);
321
        header("Content-Type: {$this->mimetype}; charset=utf-8");
322
        header('Etag: "' . $this->get_etag() . '"');
323
        if ($lastmodified) {
324
            header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastmodified) . ' GMT');
325
        }
326
        die;
327
    }
328
 
329
    /**
330
     * Sends a 404 message to indicate that the content was not found.
331
     */
332
    protected function send_not_found(): void {
333
        header('HTTP/1.0 404 not found');
334
        die('TinyMCE file was not found, sorry.');
335
    }
336
}
337
 
338
new loader();