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 - Language Producer.
  19   *
  20   * @package    editor_tiny
  21   * @copyright  2021 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  // comment out when debugging or better look into error log!
  29  define('NO_DEBUG_DISPLAY', true);
  30  
  31  // We need just the values from config.php and minlib.php.
  32  define('ABORT_AFTER_CONFIG', true);
  33  
  34  // This stops immediately at the beginning of lib/setup.php.
  35  require('../../../config.php');
  36  
  37  /**
  38   * An anonymous class to handle loading and serving lang files for TinyMCE.
  39   *
  40   * @copyright  2021 Andrew Lyons <andrew@nicols.co.uk>
  41   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  42   */
  43  class lang {
  44      /** @var string The language code to load */
  45      protected $lang;
  46  
  47      /** @var int The revision requested */
  48      protected $rev;
  49  
  50      /** @var bool Whether Moodle is fully loaded or not */
  51      protected $fullyloaded = false;
  52  
  53      /** @var string The complete path to the candidate file */
  54      protected $candidatefile;
  55  
  56      /**
  57       * Constructor to load and serve the langfile.
  58       */
  59      public function __construct() {
  60          $this->parse_file_information_from_url();
  61          $this->serve_file();
  62      }
  63  
  64      /**
  65       * Parse the file information from the URL.
  66       */
  67      protected function parse_file_information_from_url(): void {
  68          global $CFG;
  69  
  70          // The URL format is /[revision]/[lang].
  71          // The revision is an integer with negative values meaning the file is not cached.
  72          // The lang is a simple word with no directory separators or special characters.
  73          if ($slashargument = min_get_slash_argument()) {
  74              $slashargument = ltrim($slashargument, '/');
  75              if (substr_count($slashargument, '/') < 1) {
  76                  css_send_css_not_found();
  77              }
  78  
  79              [$rev, $lang] = explode('/', $slashargument, 2);
  80              $rev  = min_clean_param($rev, 'INT');
  81              $lang = min_clean_param($lang, 'SAFEDIR');
  82          } else {
  83              $rev  = min_optional_param('rev', 0, 'INT');
  84              $lang = min_optional_param('lang', 'standard', 'SAFEDIR');
  85          }
  86  
  87          // Retrieve the correct language by converting to Moodle's language code format.
  88          $this->lang = str_replace('-', '_', $lang);
  89          $this->rev = $rev;
  90          $this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/lang/{$this->lang}/lang.json";
  91      }
  92  
  93      /**
  94       * Serve the language pack content.
  95       */
  96      protected function serve_file(): void {
  97          // Attempt to send the cached langpack.
  98          // We only cache the file if the rev is valid.
  99          if (min_is_revision_valid_and_current($this->rev)) {
 100              if ($this->is_candidate_file_available()) {
 101                  // The send_cached_file_if_available function will exit if successful.
 102                  // In theory the file could become unavailable after checking that the file exists.
 103                  // Whilst this is unlikely, fall back to caching the content below.
 104                  $this->send_cached_pack();
 105              }
 106  
 107              // The file isn't cached yet.
 108              // Load the content. store it in the cache, and serve it.
 109              $strings = $this->load_language_pack();
 110              $this->store_lang_file($strings);
 111              $this->send_cached();
 112          } else {
 113              // If the revision is less than 0, then do not cache anything.
 114              $strings = $this->load_language_pack();
 115              $this->send_uncached($strings);
 116          }
 117      }
 118  
 119      /**
 120       * Load the full Moodle Framework.
 121       */
 122      protected function load_full_moodle(): void {
 123          global $CFG, $DB, $SESSION, $OUTPUT, $PAGE;
 124  
 125          if ($this->is_full_moodle_loaded()) {
 126              return;
 127          }
 128  
 129          // Ok, now we need to start normal moodle script, we need to load all libs and $DB.
 130          define('ABORT_AFTER_CONFIG_CANCEL', true);
 131  
 132          // Session not used here.
 133          define('NO_MOODLE_COOKIES', true);
 134  
 135          // Ignore upgrade check.
 136          define('NO_UPGRADE_CHECK', true);
 137  
 138          require("{$CFG->dirroot}/lib/setup.php");
 139          $this->fullyloaded = true;
 140      }
 141  
 142      /**
 143       * Check whether Moodle is fully loaded.
 144       *
 145       * @return bool
 146       */
 147      public function is_full_moodle_loaded(): bool {
 148          return $this->fullyloaded;
 149      }
 150  
 151      /**
 152       * Load the language pack strings.
 153       *
 154       * @return string[]
 155       */
 156      protected function load_language_pack(): array {
 157          // We need to load the full moodle API to use the string manager.
 158          $this->load_full_moodle();
 159  
 160          // We maintain a list of string identifier to original TinyMCE string.
 161          // TinyMCE uses English language strings to perform translations.
 162          $stringlist = file_get_contents(__DIR__ . "/tinystrings.json");
 163          if (empty($stringlist)) {
 164              $this->send_not_found("Failed to load strings from tinystrings.json");
 165          }
 166  
 167          $stringlist = json_decode($stringlist, true);
 168          if (empty($stringlist)) {
 169              $this->send_not_found("Failed to load strings from tinystrings.json");
 170          }
 171  
 172          // Load all strings for the TinyMCE Editor which have a prefix of `tiny:` from the Moodle String Manager.
 173          $stringmanager = get_string_manager();
 174          $translatedvalues = array_filter(
 175              $stringmanager->load_component_strings('editor_tiny', $this->lang),
 176              function(string $value, string $key): bool {
 177                  return strpos($key, 'tiny:') === 0;
 178              },
 179              ARRAY_FILTER_USE_BOTH
 180          );
 181  
 182          // We will associate the _original_ TinyMCE string to its translation, but only where it is different.
 183          // Where the original TinyMCE string matches the Moodle translation of it, we do not supply the string.
 184          $strings = [];
 185          foreach ($stringlist as $key => $value) {
 186              if (array_key_exists($key, $translatedvalues)) {
 187                  if ($translatedvalues[$key] !== $value) {
 188                      $strings[$value] = $translatedvalues[$key];
 189                  }
 190              }
 191          }
 192  
 193          // TinyMCE uses a secret string only present in some languages to set a language direction.
 194          // Rather than applying to only some languages, we just apply to all from our own langconfig.
 195          // Note: Do not rely on right_to_left() as the current language is unset.
 196          $strings['_dir'] = $stringmanager->get_string('thisdirection', 'langconfig', null, $this->lang);
 197  
 198          return $strings;
 199      }
 200  
 201      /**
 202       * Send a cached language pack.
 203       */
 204      protected function send_cached_pack(): void {
 205          global $CFG;
 206  
 207          if (file_exists($this->candidatefile)) {
 208              if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
 209                  // We do not actually need to verify the etag value because our files
 210                  // never change in cache because we increment the rev counter.
 211                  $this->send_unmodified_headers(filemtime($this->candidatefile));
 212              }
 213              $this->send_cached($this->candidatefile);
 214          }
 215      }
 216  
 217      /**
 218       * Store a langauge cache file containing all of the processed strings.
 219       *
 220       * @param string[] $strings The strings to store
 221       */
 222      protected function store_lang_file(array $strings): void {
 223          global $CFG;
 224  
 225          clearstatcache();
 226          if (!file_exists(dirname($this->candidatefile))) {
 227              @mkdir(dirname($this->candidatefile), $CFG->directorypermissions, true);
 228          }
 229  
 230          // Prevent serving of incomplete file from concurrent request,
 231          // the rename() should be more atomic than fwrite().
 232          ignore_user_abort(true);
 233  
 234          // First up write out the single file for all those using decent browsers.
 235          $content = json_encode($strings, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES);
 236  
 237          $filename = $this->candidatefile;
 238          if ($fp = fopen($filename . '.tmp', 'xb')) {
 239              fwrite($fp, $content);
 240              fclose($fp);
 241              rename($filename . '.tmp', $filename);
 242              @chmod($filename, $CFG->filepermissions);
 243              @unlink($filename . '.tmp'); // Just in case anything fails.
 244          }
 245  
 246          ignore_user_abort(false);
 247          if (connection_aborted()) {
 248              die;
 249          }
 250      }
 251  
 252      /**
 253       * Check whether the candidate file exists.
 254       *
 255       * @return bool
 256       */
 257      protected function is_candidate_file_available(): bool {
 258          return file_exists($this->candidatefile);
 259      }
 260  
 261      /**
 262       * Get the eTag for the candidate file.
 263       *
 264       * This is a unique hash based on the file arguments.
 265       * It does not need to consider the file content because we use a cache busting URL.
 266       *
 267       * @return string The eTag content
 268       */
 269      protected function get_etag(): string {
 270          $etag = [
 271              $this->lang,
 272              $this->rev,
 273          ];
 274  
 275          return sha1(implode('/', $etag));
 276      }
 277  
 278      /**
 279       * Send the candidate file, with aggressive cachign headers.
 280       *
 281       * This includdes eTags, a last-modified, and expiry approximately 90 days in the future.
 282       */
 283      protected function send_cached(): void {
 284          $path = $this->candidatefile;
 285  
 286          // 90 days only - based on Moodle point release cadence being every 3 months.
 287          $lifetime = 60 * 60 * 24 * 90;
 288  
 289          header('Etag: "' . $this->get_etag() . '"');
 290          header('Content-Disposition: inline; filename="lang.php"');
 291          header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT');
 292          header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
 293          header('Pragma: ');
 294          header('Cache-Control: public, max-age=' . $lifetime . ', immutable');
 295          header('Accept-Ranges: none');
 296          header('Content-Type: application/json; charset=utf-8');
 297          if (!min_enable_zlib_compression()) {
 298              header('Content-Length: ' . filesize($path));
 299          }
 300  
 301          readfile($path);
 302          die;
 303      }
 304  
 305      /**
 306       * Sends the content directly without caching it.
 307       *
 308       * @param string[] $strings
 309       */
 310      protected function send_uncached(array $strings): void {
 311          header('Content-Disposition: inline; filename="styles_debug.php"');
 312          header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
 313          header('Expires: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
 314          header('Pragma: ');
 315          header('Accept-Ranges: none');
 316          header('Content-Type: application/json; charset=utf-8');
 317  
 318          echo json_encode($strings, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES);
 319          die;
 320      }
 321  
 322      /**
 323       * Send file not modified headers.
 324       *
 325       * @param int $lastmodified
 326       */
 327      protected function send_unmodified_headers($lastmodified): void {
 328          // 90 days only - based on Moodle point release cadence being every 3 months.
 329          $lifetime = 60 * 60 * 24 * 90;
 330          header('HTTP/1.1 304 Not Modified');
 331          header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
 332          header('Cache-Control: public, max-age=' . $lifetime);
 333          header('Content-Type: application/json; charset=utf-8');
 334          header('Etag: "' . $this->get_etag() . '"');
 335          if ($lastmodified) {
 336              header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastmodified) . ' GMT');
 337          }
 338          die;
 339      }
 340  
 341      /**
 342       * Sends a 404 message to indicate that the content was not found.
 343       *
 344       * @param null|string $message An optional informative message to include to help debugging
 345       */
 346      protected function send_not_found(?string $message = null): void {
 347          header('HTTP/1.0 404 not found');
 348  
 349          if ($message) {
 350              die($message);
 351          } else {
 352              die('Language data was not found, sorry.');
 353          }
 354      }
 355  };
 356  
 357  $loader = new lang();