Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   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();