From 97364af71d2559825544c9715d41603ac38098a7 Mon Sep 17 00:00:00 2001
From: Benjamin Dalsass <95754414+bdalsass@users.noreply.github.com>
Date: Wed, 19 Aug 2026 08:45:49 +0200
Subject: [PATCH] Feature/8420 transaction id lifetime (#1009)
---
application/cmdbabstract.class.inc.php | 2 +-
application/transaction.class.inc.php | 47 +++++++++++-
core/config.class.inc.php | 8 ++
.../portal/src/Form/ObjectFormManager.php | 7 +-
dictionaries/cs.dictionary.itop.ui.php | 2 +-
dictionaries/da.dictionary.itop.ui.php | 2 +-
dictionaries/de.dictionary.itop.ui.php | 2 +-
dictionaries/en.dictionary.itop.ui.php | 2 +-
dictionaries/en_gb.dictionary.itop.ui.php | 2 +-
dictionaries/es_cr.dictionary.itop.ui.php | 2 +-
dictionaries/fr.dictionary.itop.ui.php | 2 +-
dictionaries/hu.dictionary.itop.ui.php | 2 +-
dictionaries/it.dictionary.itop.ui.php | 2 +-
dictionaries/ja.dictionary.itop.ui.php | 2 +-
dictionaries/nl.dictionary.itop.ui.php | 2 +-
dictionaries/pl.dictionary.itop.ui.php | 2 +-
dictionaries/pt_br.dictionary.itop.ui.php | 2 +-
dictionaries/ru.dictionary.itop.ui.php | 2 +-
dictionaries/sk.dictionary.itop.ui.php | 2 +-
dictionaries/tr.dictionary.itop.ui.php | 2 +-
dictionaries/zh_cn.dictionary.itop.ui.php | 2 +-
pages/UI.php | 4 +-
.../Base/Layout/ObjectController.php | 18 ++---
.../Controller/Links/LinkSetController.php | 17 ++---
.../application/privUITransactionFileTest.php | 74 ++++++++++++++++++-
25 files changed, 160 insertions(+), 51 deletions(-)
diff --git a/application/cmdbabstract.class.inc.php b/application/cmdbabstract.class.inc.php
index b3b97fdd7e..1899b78446 100644
--- a/application/cmdbabstract.class.inc.php
+++ b/application/cmdbabstract.class.inc.php
@@ -5203,7 +5203,7 @@ EOF
// Not in preview mode, do the update for real
$sTransactionId = utils::ReadPostedParam('transaction_id', '', 'transaction_id');
if (!utils::IsTransactionValid($sTransactionId, false)) {
- throw new Exception(Dict::S('UI:Error:ObjectAlreadyUpdated'));
+ throw new Exception(Dict::S('UI:Error:InvalidToken'));
}
utils::RemoveTransaction($sTransactionId);
}
diff --git a/application/transaction.class.inc.php b/application/transaction.class.inc.php
index 8a51cf1ca8..2668c08896 100644
--- a/application/transaction.class.inc.php
+++ b/application/transaction.class.inc.php
@@ -115,7 +115,7 @@ class privUITransactionSession
// Strictly speaking, the two lines below should be grouped together
// by a critical section
// sem_acquire($rSemIdentified);
- $id = static::GetUserPrefix().str_replace(['.', ' '], '', microtime());
+ $id = static::GetUserPrefix().str_replace(['.', ' '], '', microtime()).'-'.bin2hex(random_bytes(24));
Session::Set(['transactions', $id], true);
// sem_release($rSemIdentified);
@@ -236,7 +236,7 @@ class privUITransactionFile
self::CleanupOldTransactions();
- $sTransactionIdFullPath = tempnam(APPROOT.'data/transactions', static::GetUserPrefix());
+ $sTransactionIdFullPath = static::CreateUniqueTransactionFilePath(APPROOT.'data/transactions', static::GetUserPrefix());
file_put_contents($sTransactionIdFullPath, $iCurrentUserId, LOCK_EX);
$sTransactionIdFileName = basename($sTransactionIdFullPath);
@@ -245,6 +245,33 @@ class privUITransactionFile
return $sTransactionIdFileName;
}
+ /**
+ * Create a transaction file path with a longer random suffix and create it atomically.
+ *
+ * @param string $sDir
+ * @param string $sPrefix
+ *
+ * @return string
+ *
+ * @throws CoreException
+ */
+ private static function CreateUniqueTransactionFilePath(string $sDir, string $sPrefix): string
+ {
+ $iMaxAttempts = 10;
+ for ($i = 0; $i < $iMaxAttempts; $i++) {
+ $sFileName = $sPrefix.bin2hex(random_bytes(24)); // 48 random hex chars
+ $sFullPath = $sDir.'/'.$sFileName;
+
+ $hFile = @fopen($sFullPath, 'x');
+ if ($hFile !== false) {
+ fclose($hFile);
+ return $sFullPath;
+ }
+ }
+
+ throw new CoreException('Failed to allocate a unique transaction file name after multiple attempts.');
+ }
+
/**
* Check whether a transaction is valid or not and (optionally) remove the valid transaction from
* the session so that another call to IsTransactionValid for the same transaction id
@@ -281,6 +308,18 @@ class privUITransactionFile
return false;
}
+ $iTime = filemtime($sFilepath);
+ $iLifetime = (int) MetaModel::GetConfig()->Get('transactions_file_lifetime');
+ $iLimit = time() - $iLifetime;
+ if ($iTime < $iLimit) {
+ self::Info("IsTransactionValid: Transaction '$id' expired.");
+ if ($iLifetime < 3600) {
+ IssueLog::Warning("IsTransactionValid: Transaction '$id' expired after only $iLifetime seconds. Consider increasing the 'transactions_file_lifetime' configuration parameter.");
+ }
+ @unlink($sFilepath);
+ return false;
+ }
+
if ($bRemoveTransaction) {
$bResult = @unlink($sFilepath);
if (!$bResult) {
@@ -314,7 +353,7 @@ class privUITransactionFile
}
/**
- * Cleanup old transactions which have been pending since more than 24 hours
+ * Cleanup old transactions which have been pending since more than the lifetime defined in the configuration parameter 'transactions_file_lifetime'.
* Use filemtime instead of filectime since filectime may be affected by operations on the directory (like changing the access rights)
*/
protected static function CleanupOldTransactions($sTransactionDir = null)
@@ -327,7 +366,7 @@ class privUITransactionFile
}
clearstatcache();
- $iLimit = time() - 24 * 3600;
+ $iLimit = time() - (int) MetaModel::GetConfig()->Get('transactions_file_lifetime');
$sPattern = $sTransactionDir ? "$sTransactionDir/*" : APPROOT.'data/transactions/*';
$aTransactions = glob($sPattern);
foreach ($aTransactions as $sFileName) {
diff --git a/core/config.class.inc.php b/core/config.class.inc.php
index 9c1db2a74e..3c86ebdf40 100644
--- a/core/config.class.inc.php
+++ b/core/config.class.inc.php
@@ -1289,6 +1289,14 @@ class Config
'source_of_value' => '',
'show_in_conf_sample' => false,
],
+ 'transactions_file_lifetime' => [
+ 'type' => 'integer',
+ 'description' => 'Value in seconds for the lifetime of a transaction file (after this duration, the transaction will be invalid and the garbage collector could delete it). This only works if the "transaction_storage" parameter is set to "File".',
+ 'default' => 3600,
+ 'value' => '',
+ 'source_of_value' => '',
+ 'show_in_conf_sample' => false,
+ ],
'log_transactions' => [
'type' => 'bool',
'description' => 'Whether or not to enable the debug log for the transactions.',
diff --git a/datamodels/2.x/itop-portal-base/portal/src/Form/ObjectFormManager.php b/datamodels/2.x/itop-portal-base/portal/src/Form/ObjectFormManager.php
index 8d0bfb46d5..d1c7423b63 100644
--- a/datamodels/2.x/itop-portal-base/portal/src/Form/ObjectFormManager.php
+++ b/datamodels/2.x/itop-portal-base/portal/src/Form/ObjectFormManager.php
@@ -793,12 +793,7 @@ class ObjectFormManager extends FormManager
{
$isTransactionValid = \utils::IsTransactionValid($this->oForm->GetTransactionId(), false); //The transaction token is kept in order to preserve BC with ajax forms (the second call would fail if the token is deleted). (The GC will take care of cleaning the token for us later on)
if (!$isTransactionValid) {
- if ($this->oObject->IsNew()) {
- $sError = Dict::S('UI:Error:ObjectAlreadyCreated');
- } else {
- $sError = Dict::S('UI:Error:ObjectAlreadyUpdated');
- }
-
+ $sError = Dict::S('UI:Error:InvalidToken');
$aData['messages']['error'] += [
'_main' => [$sError],
];
diff --git a/dictionaries/cs.dictionary.itop.ui.php b/dictionaries/cs.dictionary.itop.ui.php
index 5944af7d5c..5a9b5416b7 100755
--- a/dictionaries/cs.dictionary.itop.ui.php
+++ b/dictionaries/cs.dictionary.itop.ui.php
@@ -485,7 +485,7 @@ Dict::Add('CS CZ', 'Czech', 'Čeština', [
'UI:Error:InvalidDashboard' => 'Chyba: neplatná nástěnka',
'UI:Error:MaintenanceMode' => 'Aktuálně probíhá údržba systému',
'UI:Error:MaintenanceTitle' => 'Údržba aplikace',
- 'UI:Error:InvalidToken' => 'Chyba: požadovaná operace byla již provedena (CSRF token nebyl nalezen)',
+ 'UI:Error:InvalidToken' => 'Aktuální relace je neplatná. Obnovte stránku a zkuste to znovu. Pokud problém přetrvává, odhlaste se a znovu se přihlaste.',
'UI:Error:SMTP:UnknownVendor' => 'Poskytovatel OAuth SMTP %1$s neexistuje (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Množství',
'UI:GroupBy:Count+' => 'Množství prvků',
diff --git a/dictionaries/da.dictionary.itop.ui.php b/dictionaries/da.dictionary.itop.ui.php
index 905ff33f7d..8401d3d1ae 100644
--- a/dictionaries/da.dictionary.itop.ui.php
+++ b/dictionaries/da.dictionary.itop.ui.php
@@ -485,7 +485,7 @@ Dict::Add('DA DA', 'Danish', 'Dansk', [
'UI:Error:InvalidDashboard' => 'Error: invalid dashboard~~',
'UI:Error:MaintenanceMode' => 'Application is currently in maintenance~~',
'UI:Error:MaintenanceTitle' => 'Maintenance~~',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)~~',
+ 'UI:Error:InvalidToken' => 'Den nuværende session er ugyldig. Opdater siden, og prøv igen. Hvis problemet fortsætter, skal du logge ud og logge ind igen.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)~~',
'UI:GroupBy:Count' => 'Antal',
'UI:GroupBy:Count+' => 'Antal af elementer',
diff --git a/dictionaries/de.dictionary.itop.ui.php b/dictionaries/de.dictionary.itop.ui.php
index d1a6fa67ba..44b0c1c8a3 100644
--- a/dictionaries/de.dictionary.itop.ui.php
+++ b/dictionaries/de.dictionary.itop.ui.php
@@ -484,7 +484,7 @@ Dict::Add('DE DE', 'German', 'Deutsch', [
'UI:Error:InvalidDashboard' => 'Fehler: Ungültiges Dashboard',
'UI:Error:MaintenanceMode' => 'Die Anwendung befindet sich derzeit im Wartungsmodus.',
'UI:Error:MaintenanceTitle' => 'Wartung',
- 'UI:Error:InvalidToken' => 'Fehler: The angeforderte Operation wurde bereits ausgeführt (CSRF-Token nicht gefunden)',
+ 'UI:Error:InvalidToken' => 'Die aktuelle Sitzung ist ungültig. Bitte aktualisieren Sie die Seite und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, melden Sie sich bitte ab und wieder an.',
'UI:Error:SMTP:UnknownVendor' => 'Der oAuth-SMTP-Provider %1$s existiert nicht (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Anzahl',
'UI:GroupBy:Count+' => 'Anzahl der Elemente',
diff --git a/dictionaries/en.dictionary.itop.ui.php b/dictionaries/en.dictionary.itop.ui.php
index b971c07f2c..08e44d9aad 100644
--- a/dictionaries/en.dictionary.itop.ui.php
+++ b/dictionaries/en.dictionary.itop.ui.php
@@ -503,7 +503,7 @@ Dict::Add('EN US', 'English', 'English', [
'UI:Error:InvalidDashboard' => 'Error: invalid dashboard',
'UI:Error:MaintenanceMode' => 'Application is currently in maintenance',
'UI:Error:MaintenanceTitle' => 'Maintenance',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)',
+ 'UI:Error:InvalidToken' => 'The current session is invalid. Please refresh the page and try again. If the problem persists, please log out and log in again.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)',
diff --git a/dictionaries/en_gb.dictionary.itop.ui.php b/dictionaries/en_gb.dictionary.itop.ui.php
index c1a2966c3e..1708021817 100644
--- a/dictionaries/en_gb.dictionary.itop.ui.php
+++ b/dictionaries/en_gb.dictionary.itop.ui.php
@@ -503,7 +503,7 @@ Dict::Add('EN GB', 'British English', 'British English', [
'UI:Error:InvalidDashboard' => 'Error: invalid dashboard',
'UI:Error:MaintenanceMode' => 'Application is currently in maintenance',
'UI:Error:MaintenanceTitle' => 'Maintenance',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)',
+ 'UI:Error:InvalidToken' => 'The current session is invalid. Please refresh the page and try again. If the problem persists, please log out and log in again.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)',
diff --git a/dictionaries/es_cr.dictionary.itop.ui.php b/dictionaries/es_cr.dictionary.itop.ui.php
index ebfc001e13..7c8a5b9ab0 100644
--- a/dictionaries/es_cr.dictionary.itop.ui.php
+++ b/dictionaries/es_cr.dictionary.itop.ui.php
@@ -483,7 +483,7 @@ Dict::Add('ES CR', 'Spanish', 'Español, Castellano', [
'UI:Error:InvalidDashboard' => 'Error: Dashboard inválido',
'UI:Error:MaintenanceMode' => 'La aplicación se encuentra actualmente en mantenimiento',
'UI:Error:MaintenanceTitle' => 'Mantenimiento',
- 'UI:Error:InvalidToken' => 'Error: La operación solicitada ya se habia realizado (CSRF token not found)',
+ 'UI:Error:InvalidToken' => 'La sesión actual no es válida. Actualice la página e inténtelo de nuevo. Si el problema persiste, cierre sesión y vuelva a iniciarla.',
'UI:Error:SMTP:UnknownVendor' => 'El proveedor SMTP de OAuth %1$s no existe (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Cuenta',
'UI:GroupBy:Count+' => 'Número de Elementos',
diff --git a/dictionaries/fr.dictionary.itop.ui.php b/dictionaries/fr.dictionary.itop.ui.php
index e7ab4198d8..98e1449930 100644
--- a/dictionaries/fr.dictionary.itop.ui.php
+++ b/dictionaries/fr.dictionary.itop.ui.php
@@ -498,7 +498,7 @@ Nous espérons que vous aimerez cette version autant que nous avons eu du plaisi
'UI:Error:InvalidDashboard' => 'Erreur: Le tableau de bord est invalide',
'UI:Error:MaintenanceMode' => 'L\'application est en maintenance',
'UI:Error:MaintenanceTitle' => 'Maintenance',
- 'UI:Error:InvalidToken' => 'Erreur: l\'opération a déjà été effectuée (CSRF token not found)',
+ 'UI:Error:InvalidToken' => 'La session actuelle est invalide. Veuillez actualiser la page et réessayer. Si le problème persiste, veuillez vous déconnecter puis vous reconnecter.',
'UI:Error:SMTP:UnknownVendor' => 'Le provider SMTP OAuth 2.0 %1$s n\'existe pas',
'UI:GroupBy:Count' => 'Nombre',
'UI:GroupBy:Count+' => 'Nombre d\'éléments',
diff --git a/dictionaries/hu.dictionary.itop.ui.php b/dictionaries/hu.dictionary.itop.ui.php
index 1ced02d6cb..4b20401693 100755
--- a/dictionaries/hu.dictionary.itop.ui.php
+++ b/dictionaries/hu.dictionary.itop.ui.php
@@ -487,7 +487,7 @@ Dict::Add('HU HU', 'Hungarian', 'Magyar', [
'UI:Error:InvalidDashboard' => 'Hiba: Érvénytelen műszerfal',
'UI:Error:MaintenanceMode' => 'Az alkalmazás jelenleg karbantartás alatt van',
'UI:Error:MaintenanceTitle' => 'Karbantartás',
- 'UI:Error:InvalidToken' => 'Hiba: a kért művelet már végrehajtásra került (CSRF token nem található)',
+ 'UI:Error:InvalidToken' => 'Az aktuális munkamenet érvénytelen. Frissítse az oldalt, majd próbálja újra. Ha a probléma továbbra is fennáll, jelentkezzen ki, majd jelentkezzen be újra.',
'UI:Error:SMTP:UnknownVendor' => 'A %1$s OAuth SMTP szolgáltató nem létezik (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Mennyiség',
'UI:GroupBy:Count+' => '',
diff --git a/dictionaries/it.dictionary.itop.ui.php b/dictionaries/it.dictionary.itop.ui.php
index 9be05503e8..b67bb7bf27 100644
--- a/dictionaries/it.dictionary.itop.ui.php
+++ b/dictionaries/it.dictionary.itop.ui.php
@@ -487,7 +487,7 @@ Dict::Add('IT IT', 'Italian', 'Italiano', [
'UI:Error:InvalidDashboard' => 'Errore: cruscotto non valido',
'UI:Error:MaintenanceMode' => 'L\'applicazione è attualmente in manutenzione',
'UI:Error:MaintenanceTitle' => 'Manutenzione',
- 'UI:Error:InvalidToken' => 'Errore: l\'operazione richiesta è già stata eseguita (token CSRF non trovato)',
+ 'UI:Error:InvalidToken' => 'La sessione corrente non è valida. Aggiorna la pagina e riprova. Se il problema persiste, esegui la disconnessione e accedi di nuovo.',
'UI:Error:SMTP:UnknownVendor' => 'Il fornitore OAuth SMTP %1$s non esiste (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Conteggio',
'UI:GroupBy:Count+' => '',
diff --git a/dictionaries/ja.dictionary.itop.ui.php b/dictionaries/ja.dictionary.itop.ui.php
index a3a05189ab..42e1ed416f 100644
--- a/dictionaries/ja.dictionary.itop.ui.php
+++ b/dictionaries/ja.dictionary.itop.ui.php
@@ -488,7 +488,7 @@ Dict::Add('JA JP', 'Japanese', '日本語', [
'UI:Error:InvalidDashboard' => 'Error: invalid dashboard~~',
'UI:Error:MaintenanceMode' => 'Application is currently in maintenance~~',
'UI:Error:MaintenanceTitle' => 'Maintenance~~',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)~~',
+ 'UI:Error:InvalidToken' => '現在のセッションは無効です。ページを更新してもう一度お試しください。問題が解決しない場合は、一度ログアウトしてから再度ログインしてください。',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)~~',
'UI:GroupBy:Count' => 'カウント',
'UI:GroupBy:Count+' => '要素数',
diff --git a/dictionaries/nl.dictionary.itop.ui.php b/dictionaries/nl.dictionary.itop.ui.php
index fa9ea2b33a..021edd7955 100644
--- a/dictionaries/nl.dictionary.itop.ui.php
+++ b/dictionaries/nl.dictionary.itop.ui.php
@@ -486,7 +486,7 @@ Dict::Add('NL NL', 'Dutch', 'Nederlands', [
'UI:Error:InvalidDashboard' => 'Fout: ongeldig dashboard',
'UI:Error:MaintenanceMode' => 'Toepassing is momenteel in onderhoud',
'UI:Error:MaintenanceTitle' => 'Onderhoud',
- 'UI:Error:InvalidToken' => 'Fout: de gevraagde bewerking werd al uitgevoerd (CSRF token niet gevonden)',
+ 'UI:Error:InvalidToken' => 'De huidige sessie is ongeldig. Vernieuw de pagina en probeer het opnieuw. Als het probleem aanhoudt, log dan uit en weer in.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s bestaat niet (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Aantal',
'UI:GroupBy:Count+' => 'Aantal objecten',
diff --git a/dictionaries/pl.dictionary.itop.ui.php b/dictionaries/pl.dictionary.itop.ui.php
index 0a933331c8..d42ff094d8 100644
--- a/dictionaries/pl.dictionary.itop.ui.php
+++ b/dictionaries/pl.dictionary.itop.ui.php
@@ -488,7 +488,7 @@ Dict::Add('PL PL', 'Polish', 'Polski', [
'UI:Error:InvalidDashboard' => 'Błąd: nieprawidłowy pulpit nawigacyjny',
'UI:Error:MaintenanceMode' => 'Aplikacja jest obecnie w trakcie konserwacji',
'UI:Error:MaintenanceTitle' => 'Konserwacja',
- 'UI:Error:InvalidToken' => 'Błąd: żądana operacja została już wykonana (nie znaleziono tokena CSRF)',
+ 'UI:Error:InvalidToken' => 'Bieżąca sesja jest nieprawidłowa. Odśwież stronę i spróbuj ponownie. Jeśli problem będzie się powtarzał, wyloguj się i zaloguj ponownie.',
'UI:Error:SMTP:UnknownVendor' => 'Dostawca OAuth SMTP %1$s nie istnieje (email_transport_smtp.oauth.provider)',
'UI:GroupBy:Count' => 'Licznik',
'UI:GroupBy:Count+' => 'Liczba elementów',
diff --git a/dictionaries/pt_br.dictionary.itop.ui.php b/dictionaries/pt_br.dictionary.itop.ui.php
index 57fad9b407..34b37c14db 100644
--- a/dictionaries/pt_br.dictionary.itop.ui.php
+++ b/dictionaries/pt_br.dictionary.itop.ui.php
@@ -484,7 +484,7 @@ Dict::Add('PT BR', 'Brazilian', 'Brazilian', [
'UI:Error:InvalidDashboard' => 'Erro: painel inválido',
'UI:Error:MaintenanceMode' => 'A aplicação está em manutenção',
'UI:Error:MaintenanceTitle' => 'Manutenção',
- 'UI:Error:InvalidToken' => 'Erro: A operação solicitada já foi executada (token CSRF não encontrado)',
+ 'UI:Error:InvalidToken' => 'A sessão atual é inválida. Atualize a página e tente novamente. Se o problema persistir, saia e entre novamente.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)~~',
'UI:GroupBy:Count' => 'Número',
'UI:GroupBy:Count+' => 'Número de elementos',
diff --git a/dictionaries/ru.dictionary.itop.ui.php b/dictionaries/ru.dictionary.itop.ui.php
index 6f918e5b3c..c447fddd5a 100644
--- a/dictionaries/ru.dictionary.itop.ui.php
+++ b/dictionaries/ru.dictionary.itop.ui.php
@@ -487,7 +487,7 @@ Dict::Add('RU RU', 'Russian', 'Русский', [
'UI:Error:InvalidDashboard' => 'Ошибка: недопустимый дашборд',
'UI:Error:MaintenanceMode' => 'Приложение в режиме технического обслуживания',
'UI:Error:MaintenanceTitle' => 'Техническое обслуживание',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)~~',
+ 'UI:Error:InvalidToken' => 'Текущий сеанс недействителен. Обновите страницу и повторите попытку. Если проблема сохраняется, выйдите из системы и войдите снова.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)~~',
'UI:GroupBy:Count' => 'Количество',
'UI:GroupBy:Count+' => 'Количество элементов',
diff --git a/dictionaries/sk.dictionary.itop.ui.php b/dictionaries/sk.dictionary.itop.ui.php
index e738ce09fc..d72b41448d 100644
--- a/dictionaries/sk.dictionary.itop.ui.php
+++ b/dictionaries/sk.dictionary.itop.ui.php
@@ -491,7 +491,7 @@ Dict::Add('SK SK', 'Slovak', 'Slovenčina', [
'UI:Error:InvalidDashboard' => 'Error: invalid dashboard~~',
'UI:Error:MaintenanceMode' => 'Application is currently in maintenance~~',
'UI:Error:MaintenanceTitle' => 'Maintenance~~',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)~~',
+ 'UI:Error:InvalidToken' => 'Aktuálna relácia je neplatná. Obnovte stránku a skúste to znova. Ak problém pretrváva, odhláste sa a znova sa prihláste.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)~~',
'UI:GroupBy:Count' => 'Počet',
'UI:GroupBy:Count+' => 'Number of elements~~',
diff --git a/dictionaries/tr.dictionary.itop.ui.php b/dictionaries/tr.dictionary.itop.ui.php
index cd5b17cfe9..6682357aeb 100644
--- a/dictionaries/tr.dictionary.itop.ui.php
+++ b/dictionaries/tr.dictionary.itop.ui.php
@@ -488,7 +488,7 @@ Dict::Add('TR TR', 'Turkish', 'Türkçe', [
'UI:Error:InvalidDashboard' => 'Error: invalid dashboard~~',
'UI:Error:MaintenanceMode' => 'Application is currently in maintenance~~',
'UI:Error:MaintenanceTitle' => 'Maintenance~~',
- 'UI:Error:InvalidToken' => 'Error: the requested operation has already been performed (CSRF token not found)~~',
+ 'UI:Error:InvalidToken' => 'Mevcut oturum geçersiz. Lütfen sayfayı yenileyip tekrar deneyin. Sorun devam ederse, oturumu kapatıp yeniden açın.',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP provider %1$s does not exist (email_transport_smtp.oauth.provider)~~',
'UI:GroupBy:Count' => 'Say',
'UI:GroupBy:Count+' => 'Eleman sayısı',
diff --git a/dictionaries/zh_cn.dictionary.itop.ui.php b/dictionaries/zh_cn.dictionary.itop.ui.php
index d37871fb10..96e1434885 100644
--- a/dictionaries/zh_cn.dictionary.itop.ui.php
+++ b/dictionaries/zh_cn.dictionary.itop.ui.php
@@ -503,7 +503,7 @@ Dict::Add('ZH CN', 'Chinese', '简体中文', [
'UI:Error:InvalidDashboard' => '错误: 无效的仪表盘',
'UI:Error:MaintenanceMode' => '应用正处于维护中',
'UI:Error:MaintenanceTitle' => '维护',
- 'UI:Error:InvalidToken' => '错误: 所请求的操作已执行 (没有CSRF token)',
+ 'UI:Error:InvalidToken' => '当前会话无效。请刷新页面后重试。如果问题仍然存在,请先退出登录再重新登录。',
'UI:Error:SMTP:UnknownVendor' => 'OAuth SMTP 提供者 %1$s 不存在 (email_transport_smtp.oauth.provider)',
diff --git a/pages/UI.php b/pages/UI.php
index 05e743b525..5c72206539 100644
--- a/pages/UI.php
+++ b/pages/UI.php
@@ -905,7 +905,7 @@ try {
if (!utils::IsTransactionValid($sTransactionId)) {
$sUser = UserRights::GetUser();
IssueLog::Error("UI.php '$operation' : invalid transaction_id ! data: user='$sUser'");
- $oP->p(Dict::S('UI:Error:ObjectAlreadyUpdated'));
+ $oP->p(Dict::S('UI:Error:InvalidToken'));
} else {
// For archiving the modification
$oFilter = DBObjectSearch::unserialize($sFilter);
@@ -1086,7 +1086,7 @@ try {
if (!utils::IsTransactionValid($sTransactionId)) {
$sUser = UserRights::GetUser();
IssueLog::Error("UI.php '$operation' : invalid transaction_id ! data: user='$sUser', class='$sClass'");
- $sMessage = Dict::S('UI:Error:ObjectAlreadyUpdated');
+ $sMessage = Dict::S('UI:Error:InvalidToken');
$sSeverity = 'info';
} elseif ((get_class($aStimuli[$sStimulus]) !== 'StimulusUserAction') || (UserRights::IsStimulusAllowed($sClass, $sStimulus) === UR_ALLOWED_NO)) {
$sUser = UserRights::GetUser();
diff --git a/sources/Controller/Base/Layout/ObjectController.php b/sources/Controller/Base/Layout/ObjectController.php
index 0ff57825cd..0f4e874919 100644
--- a/sources/Controller/Base/Layout/ObjectController.php
+++ b/sources/Controller/Base/Layout/ObjectController.php
@@ -7,7 +7,6 @@
namespace Combodo\iTop\Controller\Base\Layout;
-use Combodo\iTop\Application\WebPage\AjaxPage;
use ApplicationContext;
use ApplicationException;
use cmdbAbstractObject;
@@ -18,9 +17,13 @@ use Combodo\iTop\Application\UI\Base\Component\Alert\AlertUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\QuickCreate\QuickCreateHelper;
use Combodo\iTop\Application\UI\Base\Layout\Object\ObjectSummary;
use Combodo\iTop\Application\UI\Base\Layout\PageContent\PageContentFactory;
+use Combodo\iTop\Application\WebPage\AjaxPage;
+use Combodo\iTop\Application\WebPage\iTopWebPage;
+use Combodo\iTop\Application\WebPage\JsonPage;
use Combodo\iTop\Controller\AbstractController;
use Combodo\iTop\Service\Base\ObjectRepository;
use Combodo\iTop\Service\Router\Router;
+use Combodo\iTop\Service\SummaryCard\SummaryCardService;
use CoreCannotSaveObjectException;
use DBObjectSearch;
use DBObjectSet;
@@ -31,11 +34,8 @@ use Dict;
use Exception;
use IssueLog;
use iTopOwnershipLock;
-use Combodo\iTop\Application\WebPage\iTopWebPage;
-use Combodo\iTop\Application\WebPage\JsonPage;
use MetaModel;
use SecurityException;
-use Combodo\iTop\Service\SummaryCard\SummaryCardService;
use UserRights;
use utils;
@@ -361,9 +361,9 @@ JS;
IssueLog::Error(__CLASS__.'::'.__METHOD__." : invalid transaction_id ! data: user='$sUser', class='$sClass'");
if ($this->IsHandlingXmlHttpRequest()) {
- $aResult['data'] = ['error_message' => Dict::S('UI:Error:ObjectAlreadyCreated')];
+ $aResult['data'] = ['error_message' => Dict::S('UI:Error:InvalidToken')];
} else {
- $oErrorAlert = AlertUIBlockFactory::MakeForFailure(Dict::S('UI:Error:ObjectAlreadyCreated'));
+ $oErrorAlert = AlertUIBlockFactory::MakeForFailure(Dict::S('UI:Error:InvalidToken'));
$oErrorAlert->SetIsClosable(false)
->SetIsCollapsible(false);
$oPage->AddUiBlock($oErrorAlert);
@@ -552,13 +552,13 @@ JS;
IssueLog::Error(__CLASS__.'::'.__METHOD__." : invalid transaction_id ! data: user='$sUser', class='$sClass'");
if ($this->IsHandlingXmlHttpRequest()) {
- $aResult['data'] = ['error_message' => Dict::S('UI:Error:ObjectAlreadyUpdated')];
+ $aResult['data'] = ['error_message' => Dict::S('UI:Error:InvalidToken')];
} else {
$oPage->set_title(Dict::Format('UI:ModificationPageTitle_Object_Class', $oObj->GetRawName(), $sClassLabel)); // Set title will take care of the encoding
- $oPage->p("".Dict::S('UI:Error:ObjectAlreadyUpdated')."\n");
+ $oPage->p("".Dict::S('UI:Error:InvalidToken')."\n");
}
- $sMessage = Dict::Format('UI:Error:ObjectAlreadyUpdated', MetaModel::GetName(get_class($oObj)), $oObj->GetName());
+ $sMessage = Dict::Format('UI:Error:InvalidToken');
$sSeverity = 'error';
IssueLog::Trace(__CLASS__.'::'.__METHOD__.' Object not updated (invalid transaction_id)', $sClass, [
diff --git a/sources/Controller/Links/LinkSetController.php b/sources/Controller/Links/LinkSetController.php
index 9a5b278115..e5e9886a5d 100644
--- a/sources/Controller/Links/LinkSetController.php
+++ b/sources/Controller/Links/LinkSetController.php
@@ -7,19 +7,18 @@
namespace Combodo\iTop\Controller\Links;
-use Combodo\iTop\Application\WebPage\AjaxPage;
use cmdbAbstractObject;
use Combodo\iTop\Application\Helper\FormHelper;
use Combodo\iTop\Application\UI\Base\Component\Form\FormUIBlockFactory;
-use Combodo\iTop\Controller\AbstractController;
-use Combodo\iTop\Service\Links\LinkSetModel;
-use Combodo\iTop\Service\Router\Router;
-use Combodo\iTop\Service\Base\ObjectRepository;
-use Dict;
-use Exception;
+use Combodo\iTop\Application\WebPage\AjaxPage;
use Combodo\iTop\Application\WebPage\JsonPage;
+use Combodo\iTop\Controller\AbstractController;
+use Combodo\iTop\Service\Base\ObjectRepository;
+use Combodo\iTop\Service\Router\Router;
use CoreException;
use DBObject;
+use Dict;
+use Exception;
use MetaModel;
use UserRights;
use utils;
@@ -68,7 +67,7 @@ class LinkSetController extends AbstractController
$sErrorMessage = $e->getMessage();
}
} else {
- $sErrorMessage = 'invalid transaction id';
+ $sErrorMessage = Dict::S('UI:Error:InvalidToken');
}
$oPage->SetData([
'success' => $bOperationSuccess,
@@ -111,7 +110,7 @@ class LinkSetController extends AbstractController
$sErrorMessage = $e->getMessage();
}
} else {
- $sErrorMessage = 'invalid transaction id';
+ $sErrorMessage = Dict::S('UI:Error:InvalidToken');
}
$oPage->SetData([
'success' => $bOperationSuccess,
diff --git a/tests/php-unit-tests/unitary-tests/application/privUITransactionFileTest.php b/tests/php-unit-tests/unitary-tests/application/privUITransactionFileTest.php
index e46acde8cc..8fc30f81c0 100644
--- a/tests/php-unit-tests/unitary-tests/application/privUITransactionFileTest.php
+++ b/tests/php-unit-tests/unitary-tests/application/privUITransactionFileTest.php
@@ -42,9 +42,10 @@ class privUITransactionFileTest extends ItopDataTestCase
*/
public function testCleanupOldTransactions($iCleanableCreated, $iPreservableCreated, $sCleanablePrefix, $sPreservablePrefix)
{
- MetaModel::GetConfig()->Set('transactions_gc_threshold', 100);
-
- $iBaseLimit = time() - 24 * 3600; //24h
+ $oConfig = MetaModel::GetConfig();
+ $oConfig->Set('transactions_gc_threshold', 100);
+ $iOriginalLifetime = (int) $oConfig->Get('transactions_file_lifetime');
+ $iBaseLimit = time() - $iOriginalLifetime;
$sBaseDir = sys_get_temp_dir();
$sDir = "$sBaseDir/privUITransactionFileTest/cleanupOldTransactions";
@@ -185,4 +186,71 @@ class privUITransactionFileTest extends ItopDataTestCase
$bResult = privUITransactionFile::RemoveTransaction($sTransactionIdUnauthenticatedUser);
$this->assertTrue($bResult, 'Token created by unauthenticated user must be removed when no user logged');
}
+
+ /**
+ * Validate that transactions_file_lifetime drives transaction expiration.
+ *
+ * @throws \Exception
+ */
+ public function testIsTransactionValidUsesConfiguredFileLifetime()
+ {
+ $this->CreateUser(static::USER1_TEST_LOGIN, self::SAMPLE_DATA_SUPPORT_PROFILE_ID);
+ $bUserLogin = UserRights::Login(self::USER1_TEST_LOGIN);
+ $this->assertTrue($bUserLogin, 'Login with test user throw an error');
+
+ $oConfig = MetaModel::GetConfig();
+ $iOriginalLifetime = (int) $oConfig->Get('transactions_file_lifetime');
+ $iTestAgeInSeconds = 30;
+
+ try {
+ $oConfig->Set('transactions_file_lifetime', 3600);
+ $sLongLifetimeTransactionId = privUITransactionFile::GetNewTransactionId();
+ $sLongLifetimeTransactionFilePath = \utils::GetDataPath().'transactions/'.$sLongLifetimeTransactionId;
+ $bTouchSuccess = touch($sLongLifetimeTransactionFilePath, time() - $iTestAgeInSeconds);
+ $this->assertTrue($bTouchSuccess, 'Unable to age transaction file for long-lifetime scenario');
+ $bResult = privUITransactionFile::IsTransactionValid($sLongLifetimeTransactionId, false);
+ $this->assertTrue($bResult, 'Transaction should still be valid when its age is below transactions_file_lifetime');
+ privUITransactionFile::RemoveTransaction($sLongLifetimeTransactionId);
+
+ $oConfig->Set('transactions_file_lifetime', 5);
+ $sShortLifetimeTransactionId = privUITransactionFile::GetNewTransactionId();
+ $sShortLifetimeTransactionFilePath = \utils::GetDataPath().'transactions/'.$sShortLifetimeTransactionId;
+ $bTouchSuccess = touch($sShortLifetimeTransactionFilePath, time() - $iTestAgeInSeconds);
+ $this->assertTrue($bTouchSuccess, 'Unable to age transaction file for short-lifetime scenario');
+ $bResult = privUITransactionFile::IsTransactionValid($sShortLifetimeTransactionId, false);
+ $this->assertFalse($bResult, 'Transaction should be invalid when its age is above transactions_file_lifetime');
+ } finally {
+ $oConfig->Set('transactions_file_lifetime', $iOriginalLifetime);
+ }
+ }
+
+ /**
+ * @throws \SecurityException
+ * @throws \Exception
+ */
+ public function testTransactionIdsContain48RandomHexCharsForSessionAndFileStorage()
+ {
+ $this->CreateUser(static::USER1_TEST_LOGIN, self::SAMPLE_DATA_SUPPORT_PROFILE_ID);
+ $bUserLogin = UserRights::Login(self::USER1_TEST_LOGIN);
+ $this->assertTrue($bUserLogin, 'Login with test user throw an error');
+
+ $sSessionTransactionId = \privUITransactionSession::GetNewTransactionId();
+ $this->assertTransactionIdHas48HexSuffix($sSessionTransactionId);
+ \privUITransactionSession::RemoveTransaction($sSessionTransactionId);
+
+ $sFileTransactionId = privUITransactionFile::GetNewTransactionId();
+ $this->assertTransactionIdHas48HexSuffix($sFileTransactionId);
+ privUITransactionFile::RemoveTransaction($sFileTransactionId);
+ }
+
+ private function assertTransactionIdHas48HexSuffix(string $sTransactionId): void
+ {
+ $aParts = explode('-', $sTransactionId);
+ $sHexSuffix = end($aParts);
+
+ $this->assertNotFalse($sHexSuffix, 'Transaction ID suffix is missing');
+ $this->assertSame(48, strlen($sHexSuffix), "Transaction ID '$sTransactionId' must end with 48 hex chars");
+ $this->assertMatchesRegularExpression('/^[a-f0-9]{48}$/', $sHexSuffix, "Transaction ID '$sTransactionId' suffix must be lowercase hexadecimal");
+ }
+
}