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