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 - TinyMCE Loader.
*
* @package editor_tiny
* @copyright 2022 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.
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 TinyMCE JavaScript.
*
* @copyright 2021 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class loader {
/** @var string The filepath requested */
protected $filepath;
/** @var int The revision requested */
protected $rev;
/** @var string The mimetype to send */
protected $mimetype = null;
/** @var string The component to use */
protected $component;
/** @var string The complete path to the candidate file */
protected $candidatefile;
/**
* Initialise the class, parse the request and serve the content.
*/
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]/[filepath].
// The revision is an integer with negative values meaning the file is not cached.
// The filepath is a child of the TinyMCE js/tinymce directory containing all upstream code.
// The filepath is cleaned using the SAFEPATH option, which does not allow directory traversal.
if ($slashargument = min_get_slash_argument()) {
$slashargument = ltrim($slashargument, '/');
if (substr_count($slashargument, '/') < 1) {
$this->send_not_found();
}
[$rev, $filepath] = explode('/', $slashargument, 2);
$this->rev = min_clean_param($rev, 'INT');
$this->filepath = min_clean_param($filepath, 'SAFEPATH');
} else {
$this->rev = min_optional_param('rev', 0, 'INT');
$this->filepath = min_optional_param('filepath', 'standard', 'SAFEPATH');
}
$extension = pathinfo($this->filepath, PATHINFO_EXTENSION);
if ($extension === 'css') {
$this->mimetype = 'text/css';
} else if ($extension === 'js') {
$this->mimetype = 'application/javascript';
} else if ($extension === 'map') {
$this->mimetype = 'application/json';
} else {
$this->send_not_found();
}
$filepathhash = sha1("{$this->filepath}");
if (preg_match('/^plugins\/tiny_/', $this->filepath)) {
$parts = explode('/', $this->filepath);
array_shift($parts);
$component = array_shift($parts);
$this->component = preg_replace('/^tiny_/', '', $component);
$this->filepath = implode('/', $parts);
}
$this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/{$filepathhash}";
}
/**
* Serve the requested file from the most appropriate location, caching if possible.
*/
public function serve_file(): void {
// Attempt to send the cached filepathpack.
// 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_file_if_available();
}
// The file isn't cached yet.
// Store it in the cache and serve it.
$this->store_filepath_file();
$this->send_cached();
} else {
// If the revision is less than 0, then do not cache anything.
// Moodle is configured to not cache javascript or css.
$this->send_uncached_from_dirroot();
}
}
/**
* Get the full filepath to the requested file.
*
* @return string
*/
protected function get_filepath_from_dirroot(): ?string {
global $CFG;
$rootdir = "{$CFG->dirroot}/lib/editor/tiny";
if ($this->component) {
$rootdir .= "/plugins/{$this->component}/js";
} else {
$rootdir .= "/js/tinymce";
}
$filepath = "{$rootdir}/{$this->filepath}";
if (file_exists($filepath)) {
return $filepath;
}
return null;
}
/**
* Load the file content from the dirroot.
*
* @return string
*/
protected function load_content_from_dirroot(): ?string {
if ($filepath = $this->get_filepath_from_dirroot()) {
return file_get_contents($filepath);
}
return null;
}
/**
* Send the file content from the dirroot.
*
* If the file is not found, send the 404 response instead.
*/
protected function send_uncached_from_dirroot(): void {
if ($filepath = $this->get_filepath_from_dirroot()) {
$this->send_uncached_file($filepath);
}
$this->send_not_found();
}
/**
* Check whether the candidate file exists.
*
* @return bool
*/
protected function is_candidate_file_available(): bool {
return file_exists($this->candidatefile);
}
/**
* Send the candidate file.
*/
protected function send_cached_file_if_available(): void {
global $_SERVER;
if (file_exists($this->candidatefile)) {
// The candidate file exists so will be sent regardless.
if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
// The browser sent headers to check if the file has changed.
// We do not actually need to verify the eTag value or compare modification headers because our files
// never change in cache. When changes are made we increment the revision counter.
$this->send_unmodified_headers(filemtime($this->candidatefile));
}
// No modification headers were sent so simply serve the file from cache.
$this->send_cached($this->candidatefile);
}
}
/**
* Store the file content in the candidate file.
*/
protected function store_filepath_file(): 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);
$filename = $this->candidatefile;
if ($fp = fopen($filename . '.tmp', 'xb')) {
$content = $this->load_content_from_dirroot();
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;
}
}
/**
* 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->filepath,
$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="filepath.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: {$this->mimetype}; charset=utf-8");
if (!min_enable_zlib_compression()) {
header('Content-Length: ' . filesize($path));
}
readfile($path);
die;
}
/**
* Sends the content directly without caching it.
*
* No aggressive caching is used, and the expiry is set to the current time.
*
* @param string $filepath
*/
protected function send_uncached_file(string $filepath): 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: {$this->mimetype}; charset=utf-8");
readfile($filepath);
die;
}
/**
* Send headers to indicate that the file has not been modified at all
*
* @param int $lastmodified
*/
protected function send_unmodified_headers(int $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: {$this->mimetype}; 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.
*/
protected function send_not_found(): void {
header('HTTP/1.0 404 not found');
die('TinyMCE file was not found, sorry.');
}
}
new loader();