diff --git a/htdocs/assets/js/keepalive.js b/htdocs/assets/js/keepalive.js new file mode 100644 index 000000000000..0beb4296fb67 --- /dev/null +++ b/htdocs/assets/js/keepalive.js @@ -0,0 +1,61 @@ +/* keepalive.js */ + +document.addEventListener("DOMContentLoaded", function () { + /* 1 minute in milliseconds */ + const KEEPALIVE_INTERVAL = 1 * 60 * 1000; + + /* Keepalive URL */ + const scriptTag = document.getElementById("keepalive-script"); + const KEEPALIVE_URL = scriptTag ? scriptTag.dataset.keepaliveUrl : ""; + + if (!KEEPALIVE_URL) { + return; + } + + /* Active request controller */ + let inflightController = null; + + /* Periodic keepalive */ + setInterval(function () { + sendKeepAlive(); + }, KEEPALIVE_INTERVAL); + + /* Send keepalive request */ + function sendKeepAlive() { + // Abort previous request + if (inflightController) { + inflightController.abort(); + } + const controller = new AbortController(); + inflightController = controller; + + fetch(KEEPALIVE_URL, { + method: "GET", + credentials: "include", + cache: "no-store", + signal: controller.signal, + headers: { + "X-Requested-With": "XMLHttpRequest", + "Cache-Control": "no-store", + }, + }) + .then((response) => { + if (inflightController === controller) { + inflightController = null; + } + if (!response.ok) { + throw new Error("HTTP " + response.status); + } + return response.json(); + }) + .then(function () { + // Consume response + }) + .catch(() => { + if (inflightController === controller) { + inflightController = null; + } + // Ignore errors + }); + } +}); diff --git a/htdocs/keepalive.php b/htdocs/keepalive.php new file mode 100644 index 000000000000..c81abc0644ef --- /dev/null +++ b/htdocs/keepalive.php @@ -0,0 +1,66 @@ + "Method not allowed"]); + exit(); +} + +/* X-Requested-With header check */ +if (xoops_getenv('HTTP_X_REQUESTED_WITH') !== 'XMLHttpRequest') { + http_response_code(400); + header("Content-Type: application/json"); + echo json_encode(["error" => "Invalid request"]); + exit(); +} + +/* Referer validation */ +$_keepaliveReferer = xoops_getenv('HTTP_REFERER'); +if ($_keepaliveReferer !== '' && strpos($_keepaliveReferer, ICMS_URL) !== 0) { + http_response_code(403); + header("Content-Type: application/json"); + echo json_encode(["error" => "Invalid request"]); + exit(); +} +unset($_keepaliveReferer); + +/* Authenticated user only */ +if (!is_object(icms::$user) || icms::$user->isGuest()) { + http_response_code(403); + header("Content-Type: application/json"); + echo json_encode(["error" => "Not authenticated"]); + exit(); +} + +/* Session rate limit */ +define('KEEPALIVE_MIN_INTERVAL', 60); +if ( + isset($_SESSION['keepalive_last']) && + (time() - (int) $_SESSION['keepalive_last']) < KEEPALIVE_MIN_INTERVAL +) { + $_keepaliveRetryAfter = KEEPALIVE_MIN_INTERVAL - (time() - (int) $_SESSION['keepalive_last']); + http_response_code(429); + header("Content-Type: application/json"); + header("Retry-After: " . $_keepaliveRetryAfter); + echo json_encode(["error" => "Too many requests"]); + exit(); +} +$_SESSION['keepalive_last'] = time(); + +/* Response headers */ +header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); +header("Pragma: no-cache"); +header("Expires: 0"); +header("X-Content-Type-Options: nosniff"); + +/* JSON response */ +header("Content-Type: application/json"); +echo json_encode(["status" => "ok"]); +exit(); diff --git a/htdocs/plugins/preloads/keepalive.php b/htdocs/plugins/preloads/keepalive.php new file mode 100644 index 000000000000..73f597e9ca25 --- /dev/null +++ b/htdocs/plugins/preloads/keepalive.php @@ -0,0 +1,46 @@ +isGuest() // guest users are ignored + ) { + return; + } + + /* ------------------------------------------------------------------ + * Register the external script, attaching a unique id + * and the keep‑alive endpoint as a data attribute. + */ + $keepaliveUrl = ICMS_URL . "/keepalive.php"; + + $xoTheme->addScript( + "assets/js/keepalive.js", + [ + "id" => "keepalive-script", + "data-keepalive-url" => $keepaliveUrl, + ], + "", // no inline content + "module", + 0, // default weight + ); + } + + public function eventAdminHeader() + { + $this->eventBeforeFooter(); + } +}