MDL-75271 editor_tiny: Add a cache-busting loader for TinyMCE

Part of MDL-75966

This commit adds a cache-busting loader API for use in the TinyMCE
plugin.

This is not for use in any TinyMCE subplugins at this time as we have no
use-case outside of AMD modules.

This loader ensures that only files within the js/tiny directory are
loaded, and it only supports either .js or .css files at this time.

The client-side of the loader makes use of the jsrevision as a
cache-buster, including for CSS files included with TinyMCE.

If the revision is negative, then files are not cached.
If the revision is positive, then the requested file is cached in a
candidate file and served using aggressive cache headers.
This commit is contained in:
Andrew Nicols 2022-07-21 15:09:52 +08:00
parent d8cf77a127
commit 90c40fba5d
7 changed files with 344 additions and 6 deletions

View File

@ -1,3 +1,3 @@
define("editor_tiny/editor",["exports","./loader","core/pending"],(function(_exports,_loader,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.remove(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);return setupForTarget(target,options)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=options=>options.plugins?options.plugins:defaultOptions.plugins?defaultOptions.plugins:{},getStandardConfig=(target,tinyMCE,options,plugins)=>({target:target,language:document.querySelector("html").lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,toolbar_mode:"sliding",toolbar:[{name:"history",items:["undo","redo"]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],menu:{},plugins:[...plugins],skin:"oxide",promotion:!1}),setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]),{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);pluginConfig.forEach((pluginConfig=>{"function"==typeof pluginConfig.configure&&Object.assign(instanceConfig,pluginConfig.configure(instanceConfig))}));const[editor]=await tinyMCE.init(instanceConfig);return instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm)})),editor.moodleOptions=options,pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}}));
define("editor_tiny/editor",["exports","./loader","core/pending"],(function(_exports,_loader,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.setupForTarget=_exports.setupForElementId=_exports.getInstanceForElementId=_exports.getInstanceForElement=_exports.getAllInstances=_exports.configureDefaultEditor=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};var _systemImportTransformerGlobalIdentifier="undefined"!=typeof window?window:"undefined"!=typeof self?self:"undefined"!=typeof global?global:{};const instanceMap=new Map;let defaultOptions={};const importPluginList=async pluginList=>{const pluginHandlers=await Promise.all(pluginList.map((pluginPath=>-1===pluginPath.indexOf("/")?Promise.resolve(pluginPath):"function"==typeof _systemImportTransformerGlobalIdentifier.define&&_systemImportTransformerGlobalIdentifier.define.amd?new Promise((function(resolve,reject){_systemImportTransformerGlobalIdentifier.require([pluginPath],resolve,reject)})):"undefined"!=typeof module&&module.exports&&"undefined"!=typeof require||"undefined"!=typeof module&&module.component&&_systemImportTransformerGlobalIdentifier.require&&"component"===_systemImportTransformerGlobalIdentifier.require.loader?Promise.resolve(require(pluginPath)):Promise.resolve(_systemImportTransformerGlobalIdentifier[pluginPath])))),pluginNames=pluginHandlers.map((pluginConfig=>"string"==typeof pluginConfig?pluginConfig:Array.isArray(pluginConfig)?pluginConfig[0]:null)).filter((value=>value));return{pluginNames:pluginNames,pluginConfig:pluginHandlers.map((pluginConfig=>Array.isArray(pluginConfig)?pluginConfig[1]:null)).filter((value=>value))}};_exports.getAllInstances=()=>new Map(instanceMap.entries());_exports.getInstanceForElementId=elementId=>getInstanceForElement(document.getElementById(elementId));const getInstanceForElement=element=>{const instance=instanceMap.get(element);if(!instance||!instance.removed)return instance;instanceMap.remove(element)};_exports.getInstanceForElement=getInstanceForElement;_exports.setupForElementId=_ref=>{let{elementId:elementId,options:options}=_ref;const target=document.getElementById(elementId);return setupForTarget(target,options)};(async()=>{const lang=document.querySelector("html").lang,[tinyMCE,langData]=await Promise.all([(0,_loader.getTinyMCE)(),(language=lang,fetch("".concat(M.cfg.wwwroot,"/lib/editor/tiny/lang.php/").concat(M.cfg.langrev,"/").concat(language)).then((response=>response.json())))]);var language;tinyMCE.addI18n(lang,langData)})();const getPlugins=options=>options.plugins?options.plugins:defaultOptions.plugins?defaultOptions.plugins:{},getStandardConfig=(target,tinyMCE,options,plugins)=>{const lang=document.querySelector("html").lang;return{base_url:_loader.baseUrl,target:target,language:lang,content_css:[options.css],convert_urls:!1,a11y_advanced_options:!0,toolbar_mode:"sliding",toolbar:[{name:"history",items:["undo","redo"]},{name:"styles",items:["styles"]},{name:"formatting",items:["bold","italic"]},{name:"alignment",items:["alignleft","aligncenter","alignright","alignjustify"]},{name:"indentation",items:["outdent","indent"]},{name:"comments",items:["addcomment"]}],menu:{},plugins:[...plugins],skin:"oxide",promotion:!1}},setupForTarget=async function(target){let options=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const instance=getInstanceForElement(target);if(instance)return Promise.resolve(instance);const pendingPromise=new _pending.default("editor_tiny/editor:setupForTarget"),plugins=getPlugins(options),[tinyMCE,pluginValues]=await Promise.all([(0,_loader.getTinyMCE)(),importPluginList(Object.keys(plugins))]),{pluginNames:pluginNames,pluginConfig:pluginConfig}=pluginValues,instanceConfig=getStandardConfig(target,0,options,pluginNames);pluginConfig.forEach((pluginConfig=>{"function"==typeof pluginConfig.configure&&Object.assign(instanceConfig,pluginConfig.configure(instanceConfig))}));const[editor]=await tinyMCE.init(instanceConfig);return instanceMap.set(target,editor),editor.on("remove",(_ref2=>{let{target:target}=_ref2;instanceMap.delete(target.targetElm)})),editor.moodleOptions=options,pendingPromise.resolve(),editor};_exports.setupForTarget=setupForTarget;_exports.configureDefaultEditor=function(){let options=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};defaultOptions=options}}));
//# sourceMappingURL=editor.min.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
define("editor_tiny/loader",["exports"],(function(_exports){
define("editor_tiny/loader",["exports","core/config"],(function(_exports,Config){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}
/**
* Tiny Loader for Moodle
*
@ -6,6 +6,6 @@ define("editor_tiny/loader",["exports"],(function(_exports){
* @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
let tinyMCEPromise;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getTinyMCE=void 0;_exports.getTinyMCE=()=>tinyMCEPromise||(tinyMCEPromise=new Promise(((resolve,reject)=>{const head=document.querySelector("head");let script=head.querySelector('script[data-tinymce="tinymce"]');script&&resolve(window.tinyMCE),script=document.createElement("script"),script.dataset.tinymce="tinymce",script.src="".concat(M.cfg.wwwroot,"/lib/editor/tiny/js/tinymce/tinymce.js"),script.async=!0,script.addEventListener("load",(()=>{resolve(window.tinyMCE)}),!1),script.addEventListener("error",(err=>{reject(err)}),!1),head.append(script)})),tinyMCEPromise)}));
let tinyMCEPromise;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.getTinyMCE=_exports.baseUrl=void 0,Config=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(Config);const baseUrl="".concat(Config.wwwroot,"/lib/editor/tiny/loader.php/").concat(M.cfg.jsrev);_exports.baseUrl=baseUrl;_exports.getTinyMCE=()=>tinyMCEPromise||(tinyMCEPromise=new Promise(((resolve,reject)=>{const head=document.querySelector("head");let script=head.querySelector('script[data-tinymce="tinymce"]');script&&resolve(window.tinyMCE),script=document.createElement("script"),script.dataset.tinymce="tinymce",script.src="".concat(baseUrl,"/tinymce.js"),script.async=!0,script.addEventListener("load",(()=>{resolve(window.tinyMCE)}),!1),script.addEventListener("error",(err=>{reject(err)}),!1),head.append(script)})),tinyMCEPromise)}));
//# sourceMappingURL=loader.min.js.map

View File

@ -1 +1 @@
{"version":3,"file":"loader.min.js","sources":["../src/loader.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Tiny Loader for Moodle\n *\n * @module editor_tiny/loader\n * @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nlet tinyMCEPromise;\n\n/**\n * Get the TinyMCE API Object.\n *\n * @returns {Promise<TinyMCE>} The TinyMCE API Object\n */\nexport const getTinyMCE = () => {\n if (tinyMCEPromise) {\n return tinyMCEPromise;\n }\n\n tinyMCEPromise = new Promise((resolve, reject) => {\n const head = document.querySelector('head');\n let script = head.querySelector('script[data-tinymce=\"tinymce\"]');\n if (script) {\n resolve(window.tinyMCE);\n }\n\n script = document.createElement('script');\n script.dataset.tinymce = 'tinymce';\n script.src = `${M.cfg.wwwroot}/lib/editor/tiny/js/tinymce/tinymce.js`;\n script.async = true;\n\n script.addEventListener('load', () => {\n resolve(window.tinyMCE);\n }, false);\n\n script.addEventListener('error', (err) => {\n reject(err);\n }, false);\n\n head.append(script);\n });\n\n return tinyMCEPromise;\n};\n"],"names":["tinyMCEPromise","Promise","resolve","reject","head","document","querySelector","script","window","tinyMCE","createElement","dataset","tinymce","src","M","cfg","wwwroot","async","addEventListener","err","append"],"mappings":";;;;;;;;IAuBIA,sHAOsB,IAClBA,iBAIJA,eAAiB,IAAIC,SAAQ,CAACC,QAASC,gBAC7BC,KAAOC,SAASC,cAAc,YAChCC,OAASH,KAAKE,cAAc,kCAC5BC,QACAL,QAAQM,OAAOC,SAGnBF,OAASF,SAASK,cAAc,UAChCH,OAAOI,QAAQC,QAAU,UACzBL,OAAOM,cAASC,EAAEC,IAAIC,kDACtBT,OAAOU,OAAQ,EAEfV,OAAOW,iBAAiB,QAAQ,KAC5BhB,QAAQM,OAAOC,YAChB,GAEHF,OAAOW,iBAAiB,SAAUC,MAC9BhB,OAAOgB,QACR,GAEHf,KAAKgB,OAAOb,WAGTP"}
{"version":3,"file":"loader.min.js","sources":["../src/loader.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Tiny Loader for Moodle\n *\n * @module editor_tiny/loader\n * @copyright 2022 Andrew Lyons <andrew@nicols.co.uk>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nlet tinyMCEPromise;\n\nimport * as Config from 'core/config';\n\nexport const baseUrl = `${Config.wwwroot}/lib/editor/tiny/loader.php/${M.cfg.jsrev}`;\n\n/**\n * Get the TinyMCE API Object.\n *\n * @returns {Promise<TinyMCE>} The TinyMCE API Object\n */\nexport const getTinyMCE = () => {\n if (tinyMCEPromise) {\n return tinyMCEPromise;\n }\n\n tinyMCEPromise = new Promise((resolve, reject) => {\n const head = document.querySelector('head');\n let script = head.querySelector('script[data-tinymce=\"tinymce\"]');\n if (script) {\n resolve(window.tinyMCE);\n }\n\n script = document.createElement('script');\n script.dataset.tinymce = 'tinymce';\n script.src = `${baseUrl}/tinymce.js`;\n script.async = true;\n\n script.addEventListener('load', () => {\n resolve(window.tinyMCE);\n }, false);\n\n script.addEventListener('error', (err) => {\n reject(err);\n }, false);\n\n head.append(script);\n });\n\n return tinyMCEPromise;\n};\n"],"names":["tinyMCEPromise","baseUrl","Config","wwwroot","M","cfg","jsrev","Promise","resolve","reject","head","document","querySelector","script","window","tinyMCE","createElement","dataset","tinymce","src","async","addEventListener","err","append"],"mappings":";;;;;;;;IAuBIA,qxBAISC,kBAAaC,OAAOC,+CAAsCC,EAAEC,IAAIC,oDAOnD,IAClBN,iBAIJA,eAAiB,IAAIO,SAAQ,CAACC,QAASC,gBAC7BC,KAAOC,SAASC,cAAc,YAChCC,OAASH,KAAKE,cAAc,kCAC5BC,QACAL,QAAQM,OAAOC,SAGnBF,OAASF,SAASK,cAAc,UAChCH,OAAOI,QAAQC,QAAU,UACzBL,OAAOM,cAASlB,uBAChBY,OAAOO,OAAQ,EAEfP,OAAOQ,iBAAiB,QAAQ,KAC5Bb,QAAQM,OAAOC,YAChB,GAEHF,OAAOQ,iBAAiB,SAAUC,MAC9Bb,OAAOa,QACR,GAEHZ,KAAKa,OAAOV,WAGTb"}

View File

@ -22,6 +22,7 @@
*/
import {
getTinyMCE,
baseUrl,
} from './loader';
import Pending from 'core/pending';
@ -141,6 +142,8 @@ const getPlugins = (options) => {
const getStandardConfig = (target, tinyMCE, options, plugins) => {
const lang = document.querySelector('html').lang;
return {
base_url: baseUrl,
// Set the editor target.
// https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#target
target,

View File

@ -23,6 +23,10 @@
let tinyMCEPromise;
import * as Config from 'core/config';
export const baseUrl = `${Config.wwwroot}/lib/editor/tiny/loader.php/${M.cfg.jsrev}`;
/**
* Get the TinyMCE API Object.
*
@ -42,7 +46,7 @@ export const getTinyMCE = () => {
script = document.createElement('script');
script.dataset.tinymce = 'tinymce';
script.src = `${M.cfg.wwwroot}/lib/editor/tiny/js/tinymce/tinymce.js`;
script.src = `${baseUrl}/tinymce.js`;
script.async = true;
script.addEventListener('load', () => {

331
lib/editor/tiny/loader.php Normal file
View File

@ -0,0 +1,331 @@
<?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;
/**
* 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, 'RAW');
$this->filepath = min_clean_param($filepath, 'SAFEPATH');
} else {
$this->rev = min_optional_param('rev', 0, 'RAW');
$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.
if ($this->rev > 0) {
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();