Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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();