AutorÃa | Ultima modificación | Ver Log |
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Tiny text editor integration - Language Producer.
*
* @package editor_tiny
* @copyright 2021 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace editor_tiny;
// Disable moodle specific debug messages and any errors in output,
// comment out when debugging or better look into error log!
define('NO_DEBUG_DISPLAY', true);
// We need just the values from config.php and minlib.php.
define('ABORT_AFTER_CONFIG', true);
// This stops immediately at the beginning of lib/setup.php.
require('../../../config.php');
/**
* An anonymous class to handle loading and serving lang files for TinyMCE.
*
* @copyright 2021 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class lang {
/** @var string The language code to load */
protected $lang;
/** @var int The revision requested */
protected $rev;
/** @var bool Whether Moodle is fully loaded or not */
protected $fullyloaded = false;
/** @var string The complete path to the candidate file */
protected $candidatefile;
/**
* Constructor to load and serve the langfile.
*/
public function __construct() {
$this->parse_file_information_from_url();
$this->serve_file();
}
/**
* Parse the file information from the URL.
*/
protected function parse_file_information_from_url(): void {
global $CFG;
// The URL format is /[revision]/[lang].
// The revision is an integer with negative values meaning the file is not cached.
// The lang is a simple word with no directory separators or special characters.
if ($slashargument = min_get_slash_argument()) {
$slashargument = ltrim($slashargument, '/');
if (substr_count($slashargument, '/') < 1) {
css_send_css_not_found();
}
[$rev, $lang] = explode('/', $slashargument, 2);
$rev = min_clean_param($rev, 'INT');
$lang = min_clean_param($lang, 'SAFEDIR');
} else {
$rev = min_optional_param('rev', 0, 'INT');
$lang = min_optional_param('lang', 'standard', 'SAFEDIR');
}
// Retrieve the correct language by converting to Moodle's language code format.
$this->lang = str_replace('-', '_', $lang);
$this->rev = $rev;
$this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/lang/{$this->lang}/lang.json";
}
/**
* Serve the language pack content.
*/
protected function serve_file(): void {
// Attempt to send the cached langpack.
// We only cache the file if the rev is valid.
if (min_is_revision_valid_and_current($this->rev)) {
if ($this->is_candidate_file_available()) {
// The send_cached_file_if_available function will exit if successful.
// In theory the file could become unavailable after checking that the file exists.
// Whilst this is unlikely, fall back to caching the content below.
$this->send_cached_pack();
}
// The file isn't cached yet.
// Load the content. store it in the cache, and serve it.
$strings = $this->load_language_pack();
$this->store_lang_file($strings);
$this->send_cached();
} else {
// If the revision is less than 0, then do not cache anything.
$strings = $this->load_language_pack();
$this->send_uncached($strings);
}
}
/**
* Load the full Moodle Framework.
*/
protected function load_full_moodle(): void {
global $CFG, $DB, $SESSION, $OUTPUT, $PAGE;
if ($this->is_full_moodle_loaded()) {
return;
}
// Ok, now we need to start normal moodle script, we need to load all libs and $DB.
define('ABORT_AFTER_CONFIG_CANCEL', true);
// Session not used here.
define('NO_MOODLE_COOKIES', true);
// Ignore upgrade check.
define('NO_UPGRADE_CHECK', true);
require("{$CFG->dirroot}/lib/setup.php");
$this->fullyloaded = true;
}
/**
* Check whether Moodle is fully loaded.
*
* @return bool
*/
public function is_full_moodle_loaded(): bool {
return $this->fullyloaded;
}
/**
* Load the language pack strings.
*
* @return string[]
*/
protected function load_language_pack(): array {
// We need to load the full moodle API to use the string manager.
$this->load_full_moodle();
// We maintain a list of string identifier to original TinyMCE string.
// TinyMCE uses English language strings to perform translations.
$stringlist = file_get_contents(__DIR__ . "/tinystrings.json");
if (empty($stringlist)) {
$this->send_not_found("Failed to load strings from tinystrings.json");
}
$stringlist = json_decode($stringlist, true);
if (empty($stringlist)) {
$this->send_not_found("Failed to load strings from tinystrings.json");
}
// Load all strings for the TinyMCE Editor which have a prefix of `tiny:` from the Moodle String Manager.
$stringmanager = get_string_manager();
$translatedvalues = array_filter(
$stringmanager->load_component_strings('editor_tiny', $this->lang),
function(string $value, string $key): bool {
return strpos($key, 'tiny:') === 0;
},
ARRAY_FILTER_USE_BOTH
);
// We will associate the _original_ TinyMCE string to its translation, but only where it is different.
// Where the original TinyMCE string matches the Moodle translation of it, we do not supply the string.
$strings = [];
foreach ($stringlist as $key => $value) {
if (array_key_exists($key, $translatedvalues)) {
if ($translatedvalues[$key] !== $value) {
$strings[$value] = $translatedvalues[$key];
}
}
}
// TinyMCE uses a secret string only present in some languages to set a language direction.
// Rather than applying to only some languages, we just apply to all from our own langconfig.
// Note: Do not rely on right_to_left() as the current language is unset.
$strings['_dir'] = $stringmanager->get_string('thisdirection', 'langconfig', null, $this->lang);
return $strings;
}
/**
* Send a cached language pack.
*/
protected function send_cached_pack(): void {
global $CFG;
if (file_exists($this->candidatefile)) {
if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
// We do not actually need to verify the etag value because our files
// never change in cache because we increment the rev counter.
$this->send_unmodified_headers(filemtime($this->candidatefile));
}
$this->send_cached($this->candidatefile);
}
}
/**
* Store a langauge cache file containing all of the processed strings.
*
* @param string[] $strings The strings to store
*/
protected function store_lang_file(array $strings): void {
global $CFG;
clearstatcache();
if (!file_exists(dirname($this->candidatefile))) {
@mkdir(dirname($this->candidatefile), $CFG->directorypermissions, true);
}
// Prevent serving of incomplete file from concurrent request,
// the rename() should be more atomic than fwrite().
ignore_user_abort(true);
// First up write out the single file for all those using decent browsers.
$content = json_encode($strings, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES);
$filename = $this->candidatefile;
if ($fp = fopen($filename . '.tmp', 'xb')) {
fwrite($fp, $content);
fclose($fp);
rename($filename . '.tmp', $filename);
@chmod($filename, $CFG->filepermissions);
@unlink($filename . '.tmp'); // Just in case anything fails.
}
ignore_user_abort(false);
if (connection_aborted()) {
die;
}
}
/**
* Check whether the candidate file exists.
*
* @return bool
*/
protected function is_candidate_file_available(): bool {
return file_exists($this->candidatefile);
}
/**
* Get the eTag for the candidate file.
*
* This is a unique hash based on the file arguments.
* It does not need to consider the file content because we use a cache busting URL.
*
* @return string The eTag content
*/
protected function get_etag(): string {
$etag = [
$this->lang,
$this->rev,
];
return sha1(implode('/', $etag));
}
/**
* Send the candidate file, with aggressive cachign headers.
*
* This includdes eTags, a last-modified, and expiry approximately 90 days in the future.
*/
protected function send_cached(): void {
$path = $this->candidatefile;
// 90 days only - based on Moodle point release cadence being every 3 months.
$lifetime = 60 * 60 * 24 * 90;
header('Etag: "' . $this->get_etag() . '"');
header('Content-Disposition: inline; filename="lang.php"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($path)) . ' GMT');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
header('Pragma: ');
header('Cache-Control: public, max-age=' . $lifetime . ', immutable');
header('Accept-Ranges: none');
header('Content-Type: application/json; charset=utf-8');
if (!min_enable_zlib_compression()) {
header('Content-Length: ' . filesize($path));
}
readfile($path);
die;
}
/**
* Sends the content directly without caching it.
*
* @param string[] $strings
*/
protected function send_uncached(array $strings): void {
header('Content-Disposition: inline; filename="styles_debug.php"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
header('Expires: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
header('Pragma: ');
header('Accept-Ranges: none');
header('Content-Type: application/json; charset=utf-8');
echo json_encode($strings, JSON_PRETTY_PRINT, JSON_UNESCAPED_SLASHES);
die;
}
/**
* Send file not modified headers.
*
* @param int $lastmodified
*/
protected function send_unmodified_headers($lastmodified): void {
// 90 days only - based on Moodle point release cadence being every 3 months.
$lifetime = 60 * 60 * 24 * 90;
header('HTTP/1.1 304 Not Modified');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
header('Cache-Control: public, max-age=' . $lifetime);
header('Content-Type: application/json; charset=utf-8');
header('Etag: "' . $this->get_etag() . '"');
if ($lastmodified) {
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastmodified) . ' GMT');
}
die;
}
/**
* Sends a 404 message to indicate that the content was not found.
*
* @param null|string $message An optional informative message to include to help debugging
*/
protected function send_not_found(?string $message = null): void {
header('HTTP/1.0 404 not found');
if ($message) {
die($message);
} else {
die('Language data was not found, sorry.');
}
}
};
$loader = new lang();