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);