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();
title
Description
Body
title
Description
Body
title
Description
Body
title
Body