diff --git a/grade/export/xls/grade_export_xls.php b/grade/export/xls/grade_export_xls.php index f81b0e25846ec..ec4bbcd2d15ba 100644 --- a/grade/export/xls/grade_export_xls.php +++ b/grade/export/xls/grade_export_xls.php @@ -45,6 +45,9 @@ public function print_grades() { $strgrades = get_string('grades'); + // If this file was requested from a form, then mark download as complete (before sending headers). + \core_form\util::form_download_complete(); + // Calculate file name $shortname = format_string($this->course->shortname, true, array('context' => context_course::instance($this->course->id))); $downloadfilename = clean_filename("$shortname $strgrades.xls"); diff --git a/lib/csvlib.class.php b/lib/csvlib.class.php index f159d5aaaf385..daf8da56f5be5 100644 --- a/lib/csvlib.class.php +++ b/lib/csvlib.class.php @@ -503,6 +503,9 @@ protected function send_header() { * Download the csv file. */ public function download_file() { + // If this file was requested from a form, then mark download as complete. + \core_form\util::form_download_complete(); + $this->send_header(); $this->print_csv_data(); exit; diff --git a/lib/dataformatlib.php b/lib/dataformatlib.php index 4412e962f2273..5842e53673ef2 100644 --- a/lib/dataformatlib.php +++ b/lib/dataformatlib.php @@ -54,6 +54,9 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca // Close the session so that the users other tabs in the same session are not blocked. \core\session\manager::write_close(); + // If this file was requested from a form, then mark download as complete (before sending headers). + \core_form\util::form_download_complete(); + $format->set_filename($filename); $format->send_http_headers(); // This exists to support all dataformats - see MDL-56046. diff --git a/lib/filelib.php b/lib/filelib.php index daae058311a5d..36a85d87561fa 100644 --- a/lib/filelib.php +++ b/lib/filelib.php @@ -2299,6 +2299,9 @@ function send_temp_file($path, $filename, $pathisstring=false) { $filename = urlencode($filename); } + // If this file was requested from a form, then mark download as complete. + \core_form\util::form_download_complete(); + header('Content-Disposition: attachment; filename="'.$filename.'"'); if (is_https()) { // HTTPS sites - watch out for IE! KB812935 and KB316431. header('Cache-Control: private, max-age=10, no-transform'); @@ -2450,6 +2453,9 @@ function send_file($path, $filename, $lifetime = null , $filter=0, $pathisstring if ($forcedownload) { header('Content-Disposition: attachment; filename="'.$filename.'"'); + + // If this file was requested from a form, then mark download as complete. + \core_form\util::form_download_complete(); } else if ($mimetype !== 'application/x-shockwave-flash') { // If this is an swf don't pass content-disposition with filename as this makes the flash player treat the file // as an upload and enforces security that may prevent the file from being loaded. diff --git a/lib/form/amd/build/submit.min.js b/lib/form/amd/build/submit.min.js index 46f722e574d84..2631f659ebed9 100644 --- a/lib/form/amd/build/submit.min.js +++ b/lib/form/amd/build/submit.min.js @@ -1,2 +1,2 @@ -define ("core_form/submit",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;a.init=function init(a){var b=document.getElementById(a);b.form.addEventListener("submit",function(){var a=function(){b.disabled=!0};window.addEventListener("beforeunload",a);setTimeout(function(){window.removeEventListener("beforeunload",a)},0)},!1)}}); +define ("core_form/submit",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;var b=0,c=[],d=function(a){c.push(a);if(!b){b=setInterval(function(){var a=document.cookie.split(e()+"=");if(2==a.length){f();clearInterval(b);b=0;c.forEach(function(a){a.disabled=!1})}},500)}},e=function(){return"moodledownload_"+M.cfg.sesskey},f=function(){document.cookie=encodeURIComponent(e())+"=deleted; expires="+new Date(0).toUTCString()};a.init=function init(a){var b=document.getElementById(a);if("off"===b.form.dataset.doubleSubmitProtection){return}b.form.addEventListener("submit",function(a){var c=function(){if(a.defaultPrevented||b.disabled){return}b.disabled=!0;f();d(b)};window.addEventListener("beforeunload",c);setTimeout(function(){window.removeEventListener("beforeunload",c)},0)},!1)}}); //# sourceMappingURL=submit.min.js.map diff --git a/lib/form/amd/build/submit.min.js.map b/lib/form/amd/build/submit.min.js.map index afc8bae250824..164962313cfe6 100644 --- a/lib/form/amd/build/submit.min.js.map +++ b/lib/form/amd/build/submit.min.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/submit.js"],"names":["init","elementId","button","document","getElementById","form","addEventListener","disableAction","disabled","window","setTimeout","removeEventListener"],"mappings":"sIA+BoB,QAAPA,CAAAA,IAAO,CAACC,CAAD,CAAe,CAC/B,GAAMC,CAAAA,CAAM,CAAGC,QAAQ,CAACC,cAAT,CAAwBH,CAAxB,CAAf,CACAC,CAAM,CAACG,IAAP,CAAYC,gBAAZ,CAA6B,QAA7B,CAAuC,UAAW,CAG9C,GAAMC,CAAAA,CAAa,CAAG,UAAW,CAC7BL,CAAM,CAACM,QAAP,GACH,CAFD,CAGAC,MAAM,CAACH,gBAAP,CAAwB,cAAxB,CAAwCC,CAAxC,EAIAG,UAAU,CAAC,UAAW,CAClBD,MAAM,CAACE,mBAAP,CAA2B,cAA3B,CAA2CJ,CAA3C,CACH,CAFS,CAEP,CAFO,CAGb,CAbD,IAcH,C","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 .\n\n/**\n * Submit button JavaScript. All submit buttons will be automatically disabled once the form is\n * submitted, unless that submission results in an error/cancelling the submit.\n *\n * @module core_form/submit\n * @package core_form\n * @copyright 2019 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.8\n */\n\n/**\n * Initialises submit buttons.\n *\n * @param {String} elementId Form element\n */\nexport const init = (elementId) => {\n const button = document.getElementById(elementId);\n button.form.addEventListener('submit', function() {\n // Only disable it if the browser is really going to another page as a result of the\n // submit.\n const disableAction = function() {\n button.disabled = true;\n };\n window.addEventListener('beforeunload', disableAction);\n // If there is no beforeunload event as a result of this form submit, then the form\n // submit must have been cancelled, so don't disable the button if the page is\n // unloaded later.\n setTimeout(function() {\n window.removeEventListener('beforeunload', disableAction);\n }, 0);\n }, false);\n};\n"],"file":"submit.min.js"} \ No newline at end of file +{"version":3,"sources":["../src/submit.js"],"names":["cookieListener","cookieListeningButtons","listenForDownloadCookie","button","push","setInterval","parts","document","cookie","split","getCookieName","length","clearDownloadCookie","clearInterval","forEach","disabled","M","cfg","sesskey","encodeURIComponent","Date","toUTCString","init","elementId","getElementById","form","dataset","doubleSubmitProtection","addEventListener","event","disableAction","defaultPrevented","window","setTimeout","removeEventListener"],"mappings":"kIA2BIA,CAAAA,CAAc,CAAG,C,CAGfC,CAAsB,CAAG,E,CAazBC,CAAuB,CAAG,SAACC,CAAD,CAAY,CACxCF,CAAsB,CAACG,IAAvB,CAA4BD,CAA5B,EACA,GAAI,CAACH,CAAL,CAAqB,CACjBA,CAAc,CAAGK,WAAW,CAAC,UAAM,CAE/B,GAAMC,CAAAA,CAAK,CAAGC,QAAQ,CAACC,MAAT,CAAgBC,KAAhB,CAAsBC,CAAa,GAAK,GAAxC,CAAd,CACA,GAAoB,CAAhB,EAAAJ,CAAK,CAACK,MAAV,CAAuB,CAEnBC,CAAmB,GACnBC,aAAa,CAACb,CAAD,CAAb,CACAA,CAAc,CAAG,CAAjB,CAGAC,CAAsB,CAACa,OAAvB,CAA+B,SAACX,CAAD,CAAY,CACvCA,CAAM,CAACY,QAAP,GACH,CAFD,CAGH,CACJ,CAd2B,CAczB,GAdyB,CAe/B,CACJ,C,CAOKL,CAAa,CAAG,UAAM,CACxB,MAAO,kBAAoBM,CAAC,CAACC,GAAF,CAAMC,OACpC,C,CAKKN,CAAmB,CAAG,UAAM,CAC9BL,QAAQ,CAACC,MAAT,CAAkBW,kBAAkB,CAACT,CAAa,EAAd,CAAlB,CAAsC,oBAAtC,CAA6D,GAAIU,CAAAA,IAAJ,CAAS,CAAT,EAAYC,WAAZ,EAClF,C,QAOmB,QAAPC,CAAAA,IAAO,CAACC,CAAD,CAAe,CAC/B,GAAMpB,CAAAA,CAAM,CAAGI,QAAQ,CAACiB,cAAT,CAAwBD,CAAxB,CAAf,CAEA,GAAmD,KAA/C,GAAApB,CAAM,CAACsB,IAAP,CAAYC,OAAZ,CAAoBC,sBAAxB,CAA0D,CACtD,MACH,CACDxB,CAAM,CAACsB,IAAP,CAAYG,gBAAZ,CAA6B,QAA7B,CAAuC,SAASC,CAAT,CAAgB,CAGnD,GAAMC,CAAAA,CAAa,CAAG,UAAW,CAE7B,GAAID,CAAK,CAACE,gBAAN,EAA0B5B,CAAM,CAACY,QAArC,CAA+C,CAC3C,MACH,CAEDZ,CAAM,CAACY,QAAP,IACAH,CAAmB,GACnBV,CAAuB,CAACC,CAAD,CAC1B,CATD,CAUA6B,MAAM,CAACJ,gBAAP,CAAwB,cAAxB,CAAwCE,CAAxC,EAIAG,UAAU,CAAC,UAAW,CAClBD,MAAM,CAACE,mBAAP,CAA2B,cAA3B,CAA2CJ,CAA3C,CACH,CAFS,CAEP,CAFO,CAGb,CApBD,IAqBH,C","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 .\n\n/**\n * Submit button JavaScript. All submit buttons will be automatically disabled once the form is\n * submitted, unless that submission results in an error/cancelling the submit.\n *\n * @module core_form/submit\n * @package core_form\n * @copyright 2019 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.8\n */\n\n/** @type {number} ID for setInterval used when polling for download cookie */\nlet cookieListener = 0;\n\n/** @type {Array} Array of buttons that need re-enabling if we get a download cookie */\nconst cookieListeningButtons = [];\n\n/**\n * Listens in case a download cookie is provided.\n *\n * This function is used to detect file downloads. If there is a file download then we get a\n * beforeunload event, but the page is never unloaded and when the file download completes we\n * should re-enable the buttons. We detect this by watching for a specific cookie.\n *\n * PHP function \\core_form\\util::form_download_complete() can be used to send this cookie.\n *\n * @param {HTMLElement} button Button to re-enable\n */\nconst listenForDownloadCookie = (button) => {\n cookieListeningButtons.push(button);\n if (!cookieListener) {\n cookieListener = setInterval(() => {\n // Look for cookie.\n const parts = document.cookie.split(getCookieName() + '=');\n if (parts.length == 2) {\n // We found the cookie, so the file is ready. Expire the cookie and cancel polling.\n clearDownloadCookie();\n clearInterval(cookieListener);\n cookieListener = 0;\n\n // Re-enable all the buttons.\n cookieListeningButtons.forEach((button) => {\n button.disabled = false;\n });\n }\n }, 500);\n }\n};\n\n/**\n * Gets a unique name for the download cookie.\n *\n * @returns {string} Cookie name\n */\nconst getCookieName = () => {\n return 'moodledownload_' + M.cfg.sesskey;\n};\n\n/**\n * Clears the download cookie if there is one.\n */\nconst clearDownloadCookie = () => {\n document.cookie = encodeURIComponent(getCookieName()) + '=deleted; expires=' + new Date(0).toUTCString();\n};\n\n/**\n * Initialises submit buttons.\n *\n * @param {String} elementId Form element\n */\nexport const init = (elementId) => {\n const button = document.getElementById(elementId);\n // If the form has double submit protection disabled, do nothing.\n if (button.form.dataset.doubleSubmitProtection === 'off') {\n return;\n }\n button.form.addEventListener('submit', function(event) {\n // Only disable it if the browser is really going to another page as a result of the\n // submit.\n const disableAction = function() {\n // If the submit was cancelled, or the button is already disabled, don't do anything.\n if (event.defaultPrevented || button.disabled) {\n return;\n }\n\n button.disabled = true;\n clearDownloadCookie();\n listenForDownloadCookie(button);\n };\n window.addEventListener('beforeunload', disableAction);\n // If there is no beforeunload event as a result of this form submit, then the form\n // submit must have been cancelled, so don't disable the button if the page is\n // unloaded later.\n setTimeout(function() {\n window.removeEventListener('beforeunload', disableAction);\n }, 0);\n }, false);\n};\n"],"file":"submit.min.js"} \ No newline at end of file diff --git a/lib/form/amd/src/submit.js b/lib/form/amd/src/submit.js index 1e4e7ddcbe4fc..af4271ec75b7a 100644 --- a/lib/form/amd/src/submit.js +++ b/lib/form/amd/src/submit.js @@ -24,6 +24,60 @@ * @since 3.8 */ +/** @type {number} ID for setInterval used when polling for download cookie */ +let cookieListener = 0; + +/** @type {Array} Array of buttons that need re-enabling if we get a download cookie */ +const cookieListeningButtons = []; + +/** + * Listens in case a download cookie is provided. + * + * This function is used to detect file downloads. If there is a file download then we get a + * beforeunload event, but the page is never unloaded and when the file download completes we + * should re-enable the buttons. We detect this by watching for a specific cookie. + * + * PHP function \core_form\util::form_download_complete() can be used to send this cookie. + * + * @param {HTMLElement} button Button to re-enable + */ +const listenForDownloadCookie = (button) => { + cookieListeningButtons.push(button); + if (!cookieListener) { + cookieListener = setInterval(() => { + // Look for cookie. + const parts = document.cookie.split(getCookieName() + '='); + if (parts.length == 2) { + // We found the cookie, so the file is ready. Expire the cookie and cancel polling. + clearDownloadCookie(); + clearInterval(cookieListener); + cookieListener = 0; + + // Re-enable all the buttons. + cookieListeningButtons.forEach((button) => { + button.disabled = false; + }); + } + }, 500); + } +}; + +/** + * Gets a unique name for the download cookie. + * + * @returns {string} Cookie name + */ +const getCookieName = () => { + return 'moodledownload_' + M.cfg.sesskey; +}; + +/** + * Clears the download cookie if there is one. + */ +const clearDownloadCookie = () => { + document.cookie = encodeURIComponent(getCookieName()) + '=deleted; expires=' + new Date(0).toUTCString(); +}; + /** * Initialises submit buttons. * @@ -31,11 +85,22 @@ */ export const init = (elementId) => { const button = document.getElementById(elementId); - button.form.addEventListener('submit', function() { + // If the form has double submit protection disabled, do nothing. + if (button.form.dataset.doubleSubmitProtection === 'off') { + return; + } + button.form.addEventListener('submit', function(event) { // Only disable it if the browser is really going to another page as a result of the // submit. const disableAction = function() { + // If the submit was cancelled, or the button is already disabled, don't do anything. + if (event.defaultPrevented || button.disabled) { + return; + } + button.disabled = true; + clearDownloadCookie(); + listenForDownloadCookie(button); }; window.addEventListener('beforeunload', disableAction); // If there is no beforeunload event as a result of this form submit, then the form diff --git a/lib/form/classes/util.php b/lib/form/classes/util.php new file mode 100644 index 0000000000000..2a252a3aeb0a0 --- /dev/null +++ b/lib/form/classes/util.php @@ -0,0 +1,64 @@ +. + +/** + * Provides the {@link core_form\util} class. + * + * @package core_form + * @copyright 2019 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_form; + +defined('MOODLE_INTERNAL') || die(); + +/** + * General utility class for form-related methods. + * + * @copyright 2019 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class util { + /** + * This function should be called if a form submit results in a file download (i.e. with the + * Content-Disposition: attachment header) instead of navigating to a new page, before the + * file download is sent. It will set a cookie which is used to inform page javascript in + * submit.js. + * + * You may call this function in scripts which might not necessarily be called from forms; it + * will only set the cookie if there is a POST request from a form. + * + * This is automatically called from various points in Moodle such as send_file_xx functions + * in filelib.php. + */ + public static function form_download_complete() { + // If this doesn't look like a Moodle QuickForms request, ignore. + $quickform = false; + foreach ($_POST as $name => $value) { + if (preg_match('~^_qf__~', $name)) { + $quickform = true; + break; + } + } + if (!$quickform) { + return; + } + + // Set a session cookie. + setcookie('moodledownload_' . sesskey(), time()); + } +} diff --git a/lib/formslib.php b/lib/formslib.php index 990ab73c70259..4a000a33fce32 100644 --- a/lib/formslib.php +++ b/lib/formslib.php @@ -169,6 +169,10 @@ abstract class moodleform { * @param mixed $attributes you can pass a string of html attributes here or an array. * Special attribute 'data-random-ids' will randomise generated elements ids. This * is necessary when there are several forms on the same page. + * Special attribute 'data-double-submit-protection' set to 'off' will turn off + * double-submit protection JavaScript - this may be necessary if your form sends + * downloadable files in response to a submit button, and can't call + * \core_form\util::form_download_complete(); * @param bool $editable * @param array $ajaxformdata Forms submitted via ajax, must pass their data here, instead of relying on _GET and _POST. */ diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 3075147d6aca6..9ade5075a0823 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -76,6 +76,13 @@ validation against and defaults to null (so, no user needed) if not provided. the itemid and filepath for the filearea and path defined in $args. It has been added in order to get the correct itemid and filepath because some components, such as mod_page or mod_resource, add the revision to the URL where the itemid should be placed (to prevent caching problems), but then they don't store it in database. +* New utility function \core_form\util::form_download_complete should be called if your code sends + a file with Content-Disposition: Attachment in response to a Moodle form submit button (to ensure + that disabled submit buttons get re-enabled in that case). It is automatically called by the + filelib.php send_xx functions. +* If you have a form which sends a file in response to a Moodle form submit button, but you cannot + call the above function because the file is sent by a third party library, then you should add + the attribute data-double-submit-protection="off" to your form. === 3.7 === diff --git a/mod/data/preset.php b/mod/data/preset.php index 9a166b2d37d6c..d76becd15adbc 100644 --- a/mod/data/preset.php +++ b/mod/data/preset.php @@ -140,6 +140,10 @@ header('Expires: 0'); header('Cache-Control: must-revalidate,post-check=0,pre-check=0'); header('Pragma: public'); + + // If this file was requested from a form, then mark download as complete. + \core_form\util::form_download_complete(); + $exportfilehandler = fopen($exportfile, 'rb'); print fread($exportfilehandler, filesize($exportfile)); fclose($exportfilehandler);