From 63ecfe203dc06e58c1564f3efe7b24cab2807f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=B6rner?= Date: Tue, 23 Jun 2026 08:35:59 +0200 Subject: [PATCH 1/2] add timeout support --- README.md | 4 +++ .../ExecutePreActionsListener.php | 5 ++- src/Manager/LinkChecker.php | 36 ++++++++++++++----- src/Resources/contao/languages/de/default.php | 1 + src/Resources/contao/languages/en/default.php | 1 + .../templates/backend/be_linkchecker.html5 | 4 +-- .../js/contao-linkchecker-bundle.es6.js | 23 ++++++++++++ src/Resources/public/js/linkchecker.js | 23 ++++++++++++ src/Resources/public/js/linkchecker.min.js | 2 +- src/Widget/LinkCheckerWidget.php | 15 ++++++++ 10 files changed, 102 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bccee60..1b9e642 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Provide a valid `load_callback` within you DataContainer field configuration. 'linkChecker' => array( 'label' => &$GLOBALS['TL_LANG']['tl_sample']['linkChecker'], 'inputType' => 'linkChecker', + 'eval' => array( + 'linkCheckerTimeout' => 10, + ), 'load_callback' => array( array('MyClass', 'getLinkCheckerHtml'), ), @@ -42,6 +45,7 @@ Provide a valid `load_callback` within you DataContainer field configuration. ``` You can return html-code with anchor tags, a single link or an array of links within your `load_callback`. +The `linkCheckerTimeout` eval option configures the timeout in seconds for the backend link check. If omitted or invalid, the default timeout is 10 seconds. ``` // MyClass diff --git a/src/EventListener/ExecutePreActionsListener.php b/src/EventListener/ExecutePreActionsListener.php index 6a1872c..28851a7 100644 --- a/src/EventListener/ExecutePreActionsListener.php +++ b/src/EventListener/ExecutePreActionsListener.php @@ -35,7 +35,10 @@ public function onExecutePreActions(string $action): void return; } - $strStatus = $this->linkChecker->test($request->request->get(LinkCheckerWidget::LINKCHECKER_PARAM)); + $strStatus = $this->linkChecker->test( + $request->request->get(LinkCheckerWidget::LINKCHECKER_PARAM), + $request->request->get(LinkCheckerWidget::LINKCHECKER_TIMEOUT_PARAM) + ); $objResponse = new ResponseSuccess(); $objResponse->setResult(new ResponseData($strStatus)); diff --git a/src/Manager/LinkChecker.php b/src/Manager/LinkChecker.php index bf72ae8..5304421 100644 --- a/src/Manager/LinkChecker.php +++ b/src/Manager/LinkChecker.php @@ -13,6 +13,7 @@ use Contao\Validator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class LinkChecker @@ -20,6 +21,7 @@ class LinkChecker public const STATUS_MAILTO = 'mailto'; public const STATUS_INVALID = 'invalid'; public const STATUS_TIMEOUT = '408'; + public const DEFAULT_TIMEOUT = 10; public const CLASS_DEFAULT = 'lc-default'; public const CLASS_INFO = 'lc-info'; @@ -39,13 +41,15 @@ public function __construct( * * @return array|bool|mixed */ - public function test($varLinks) + public function test($varLinks, int|string|null $timeout = null) { + $timeout = $this->normalizeTimeout($timeout); + if (!\is_array($varLinks)) { - return $this->testOne($varLinks); + return $this->testOne($varLinks, $timeout); } - return $this->testAll($varLinks); + return $this->testAll($varLinks, $timeout); } /** @@ -55,7 +59,7 @@ public function test($varLinks) * * @return string The translated status code, or false if the link was not tested */ - protected function testOne(string $url): string + protected function testOne(string $url, int $timeout): string { if (str_starts_with($url, 'mailto:')) { return $this->getResult(static::STATUS_MAILTO); @@ -65,9 +69,16 @@ protected function testOne(string $url): string return $this->getResult(static::STATUS_INVALID); } - $response = $this->client->request(Request::METHOD_GET, $url); + try { + $response = $this->client->request(Request::METHOD_GET, $url, [ + 'max_duration' => $timeout, + 'timeout' => $timeout, + ]); - return $this->getResult($response->getStatusCode()); + return $this->getResult($response->getStatusCode()); + } catch (TransportExceptionInterface) { + return $this->getResult(static::STATUS_TIMEOUT); + } } /** @@ -77,18 +88,27 @@ protected function testOne(string $url): string * * @return array The list of tested links with translated status code, or false if the link was not tested */ - protected function testAll(array $arrLinks): array + protected function testAll(array $arrLinks, int $timeout): array { $arrResults = []; foreach ($arrLinks as $strKey => $strUrl) { - $arrResults[$strUrl] = $this->testOne($strUrl); + $arrResults[$strUrl] = $this->testOne($strUrl, $timeout); unset($arrLinks); } return $arrResults; } + protected function normalizeTimeout(int|string|null $timeout): int + { + if (null === $timeout || '' === $timeout || !is_numeric($timeout) || (int) $timeout < 1) { + return static::DEFAULT_TIMEOUT; + } + + return (int) $timeout; + } + /** * Get the styled result. */ diff --git a/src/Resources/contao/languages/de/default.php b/src/Resources/contao/languages/de/default.php index 7d5829d..8b77e8e 100644 --- a/src/Resources/contao/languages/de/default.php +++ b/src/Resources/contao/languages/de/default.php @@ -18,3 +18,4 @@ */ $arrLang['statusCodes'][LinkChecker::STATUS_MAILTO] = 'E-Mail Adressen werden nicht geprüft.'; $arrLang['statusCodes'][LinkChecker::STATUS_INVALID] = 'Ungültige URL, kann nicht geprüft werden.'; +$arrLang['statusCodes'][LinkChecker::STATUS_TIMEOUT] = 'Zeitüberschreitung bei der Anfrage.'; diff --git a/src/Resources/contao/languages/en/default.php b/src/Resources/contao/languages/en/default.php index 3f17a34..c99bc48 100644 --- a/src/Resources/contao/languages/en/default.php +++ b/src/Resources/contao/languages/en/default.php @@ -18,3 +18,4 @@ */ $arrLang['statusCodes'][LinkChecker::STATUS_MAILTO] = 'Email addresses are not checked.'; $arrLang['statusCodes'][LinkChecker::STATUS_INVALID] = 'Invalid URL, cannot be checked.'; +$arrLang['statusCodes'][LinkChecker::STATUS_TIMEOUT] = 'Request timed out.'; diff --git a/src/Resources/contao/templates/backend/be_linkchecker.html5 b/src/Resources/contao/templates/backend/be_linkchecker.html5 index 0144e5a..d692a89 100644 --- a/src/Resources/contao/templates/backend/be_linkchecker.html5 +++ b/src/Resources/contao/templates/backend/be_linkchecker.html5 @@ -9,11 +9,11 @@ links as $objLink): ?> - text; ?> - \ No newline at end of file + diff --git a/src/Resources/public/js/contao-linkchecker-bundle.es6.js b/src/Resources/public/js/contao-linkchecker-bundle.es6.js index c67e9ad..18daf11 100644 --- a/src/Resources/public/js/contao-linkchecker-bundle.es6.js +++ b/src/Resources/public/js/contao-linkchecker-bundle.es6.js @@ -5,8 +5,13 @@ this.element = element; this.options = options; this.url = this.element.getAttribute('data-url'); + this.timeout = parseInt(this.element.getAttribute('data-timeout'), 10); this.element.linkchecker = this; + if (isNaN(this.timeout) || this.timeout < 1) { + this.timeout = 10; + } + if (this.element.getAttribute('data-target')) { this.target = document.querySelector(this.element.getAttribute('data-target')); } @@ -39,6 +44,7 @@ let xhr = new XMLHttpRequest(); xhr.open(method, url, true); + xhr.timeout = (_this.timeout + 2) * 1000; xhr.withCredentials = !!_this.options.withCredentials; response = null; updateProgress = function() { @@ -72,6 +78,16 @@ } }; xhr.onerror = function() { + _this._failed(); + + reject({ + status: this.status, + statusText: xhr.statusText + }); + }; + xhr.ontimeout = function() { + _this._failed(); + reject({ status: this.status, statusText: xhr.statusText @@ -101,6 +117,7 @@ formData.append(key, value); } } + formData.append('lc_timeout', _this.timeout); _requests.push(xhr); @@ -124,6 +141,12 @@ return false; }; + LinkChecker.prototype._failed = function() { + this.target.innerHTML = '-'; + + return false; + }; + LinkCheckerRegistry = { init: function() { this.register(); diff --git a/src/Resources/public/js/linkchecker.js b/src/Resources/public/js/linkchecker.js index fc41178..14920f3 100644 --- a/src/Resources/public/js/linkchecker.js +++ b/src/Resources/public/js/linkchecker.js @@ -240,8 +240,13 @@ this.element = element; this.options = options; this.url = this.element.getAttribute('data-url'); + this.timeout = parseInt(this.element.getAttribute('data-timeout'), 10); this.element.linkchecker = this; + if (isNaN(this.timeout) || this.timeout < 1) { + this.timeout = 10; + } + if (this.element.getAttribute('data-target')) { this.target = document.querySelector(this.element.getAttribute('data-target')); } @@ -276,6 +281,7 @@ var xhr = new XMLHttpRequest(); xhr.open(method, url, true); + xhr.timeout = (_this.timeout + 2) * 1000; xhr.withCredentials = !!_this.options.withCredentials; response = null; updateProgress = function() { @@ -309,6 +315,16 @@ } }; xhr.onerror = function () { + _this._failed(); + + reject({ + status: this.status, + statusText: xhr.statusText + }); + }; + xhr.ontimeout = function () { + _this._failed(); + reject({ status: this.status, statusText: xhr.statusText @@ -338,6 +354,7 @@ formData.append(key, value); } } + formData.append('lc_timeout', _this.timeout); _requests.push(xhr); @@ -361,6 +378,12 @@ return false; }; + LinkChecker.prototype._failed = function () { + this.target.innerHTML = '-'; + + return false; + }; + LinkCheckerRegistry = { init: function () { this.register(); diff --git a/src/Resources/public/js/linkchecker.min.js b/src/Resources/public/js/linkchecker.min.js index dedfd70..16a9de2 100644 --- a/src/Resources/public/js/linkchecker.min.js +++ b/src/Resources/public/js/linkchecker.min.js @@ -1 +1 @@ -!function(e){var t=setTimeout;function n(){}function r(e){if("object"!=typeof this)throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=void 0,this._deferreds=[],a(e,this)}function o(e,t){for(;3===e._state;)e=e._value;0!==e._state?(e._handled=!0,r._immediateFn(function(){var n=1===e._state?t.onFulfilled:t.onRejected;if(null!==n){var r;try{r=n(e._value)}catch(e){return void s(t.promise,e)}i(t.promise,r)}else(1===e._state?i:s)(t.promise,e._value)})):e._deferreds.push(t)}function i(e,t){try{if(t===e)throw new TypeError("A promise cannot be resolved with itself.");if(t&&("object"==typeof t||"function"==typeof t)){var n=t.then;if(t instanceof r)return e._state=3,e._value=t,void u(e);if("function"==typeof n)return void a((o=n,i=t,function(){o.apply(i,arguments)}),e)}e._state=1,e._value=t,u(e)}catch(t){s(e,t)}var o,i}function s(e,t){e._state=2,e._value=t,u(e)}function u(e){2===e._state&&0===e._deferreds.length&&r._immediateFn(function(){e._handled||r._unhandledRejectionFn(e._value)});for(var t=0,n=e._deferreds.length;tgetTimeout(); $arrLinks = []; @@ -73,6 +76,7 @@ protected function test(): string $objLink->targetID = 'lc-' . $rand . $i; $objLink->target = '#lc-' . $rand . $i; $objLink->text = StringUtil::substr($this->arrLinks[$i], 100); + $objLink->timeout = $timeout; $arrLinks[$i] = $objLink; unset($this->arrLinks[$i]); } @@ -117,6 +121,17 @@ protected function collect(): bool return \is_array($this->arrLinks) && !empty($this->arrLinks); } + protected function getTimeout(): int + { + $timeout = $this->linkCheckerTimeout ?? null; + + if (null === $timeout || '' === $timeout || !is_numeric($timeout) || (int) $timeout < 1) { + return LinkCheckerManager::DEFAULT_TIMEOUT; + } + + return (int) $timeout; + } + /** * Add links from node to test list. */ From bff2c9083cf85d375dba3b495389b00365798075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=B6rner?= Date: Tue, 23 Jun 2026 08:50:07 +0200 Subject: [PATCH 2/2] fix review reports --- src/Manager/LinkChecker.php | 10 ++++++++-- src/Resources/contao/languages/de/default.php | 1 + src/Resources/contao/languages/en/default.php | 1 + src/Widget/LinkCheckerWidget.php | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Manager/LinkChecker.php b/src/Manager/LinkChecker.php index 5304421..e57c4d0 100644 --- a/src/Manager/LinkChecker.php +++ b/src/Manager/LinkChecker.php @@ -21,7 +21,9 @@ class LinkChecker public const STATUS_MAILTO = 'mailto'; public const STATUS_INVALID = 'invalid'; public const STATUS_TIMEOUT = '408'; + public const STATUS_TRANSPORT_ERROR = 'transport_error'; public const DEFAULT_TIMEOUT = 10; + public const MAX_TIMEOUT = 60; public const CLASS_DEFAULT = 'lc-default'; public const CLASS_INFO = 'lc-info'; @@ -77,7 +79,7 @@ protected function testOne(string $url, int $timeout): string return $this->getResult($response->getStatusCode()); } catch (TransportExceptionInterface) { - return $this->getResult(static::STATUS_TIMEOUT); + return $this->getResult(static::STATUS_TRANSPORT_ERROR); } } @@ -106,7 +108,7 @@ protected function normalizeTimeout(int|string|null $timeout): int return static::DEFAULT_TIMEOUT; } - return (int) $timeout; + return min((int) $timeout, static::MAX_TIMEOUT); } /** @@ -139,6 +141,10 @@ protected function getResult(string $result): string */ protected function getStatusClass(string $statusCode): string { + if (static::STATUS_TRANSPORT_ERROR === $statusCode) { + return static::CLASS_ERROR; + } + $intStart = null; if (\strlen($statusCode) > 0) { diff --git a/src/Resources/contao/languages/de/default.php b/src/Resources/contao/languages/de/default.php index 8b77e8e..9828bd3 100644 --- a/src/Resources/contao/languages/de/default.php +++ b/src/Resources/contao/languages/de/default.php @@ -19,3 +19,4 @@ $arrLang['statusCodes'][LinkChecker::STATUS_MAILTO] = 'E-Mail Adressen werden nicht geprüft.'; $arrLang['statusCodes'][LinkChecker::STATUS_INVALID] = 'Ungültige URL, kann nicht geprüft werden.'; $arrLang['statusCodes'][LinkChecker::STATUS_TIMEOUT] = 'Zeitüberschreitung bei der Anfrage.'; +$arrLang['statusCodes'][LinkChecker::STATUS_TRANSPORT_ERROR] = 'Anfrage fehlgeschlagen.'; diff --git a/src/Resources/contao/languages/en/default.php b/src/Resources/contao/languages/en/default.php index c99bc48..21b5430 100644 --- a/src/Resources/contao/languages/en/default.php +++ b/src/Resources/contao/languages/en/default.php @@ -19,3 +19,4 @@ $arrLang['statusCodes'][LinkChecker::STATUS_MAILTO] = 'Email addresses are not checked.'; $arrLang['statusCodes'][LinkChecker::STATUS_INVALID] = 'Invalid URL, cannot be checked.'; $arrLang['statusCodes'][LinkChecker::STATUS_TIMEOUT] = 'Request timed out.'; +$arrLang['statusCodes'][LinkChecker::STATUS_TRANSPORT_ERROR] = 'Request failed.'; diff --git a/src/Widget/LinkCheckerWidget.php b/src/Widget/LinkCheckerWidget.php index 21cdd48..02076e0 100644 --- a/src/Widget/LinkCheckerWidget.php +++ b/src/Widget/LinkCheckerWidget.php @@ -129,7 +129,7 @@ protected function getTimeout(): int return LinkCheckerManager::DEFAULT_TIMEOUT; } - return (int) $timeout; + return min((int) $timeout, LinkCheckerManager::MAX_TIMEOUT); } /**