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