mirror of
https://github.com/Combodo/iTop.git
synced 2026-08-07 14:28:23 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5715e0484f | ||
|
|
d6ef4fb7bb | ||
|
|
d6ce202fa8 | ||
|
|
1c38d989e4 | ||
|
|
829857ec85 | ||
|
|
b529a61bc5 | ||
|
|
c56617abf5 | ||
|
|
7676115725 | ||
|
|
7cac280b83 | ||
|
|
8f2c990065 | ||
|
|
03437c3453 |
111
.doc/itop-version-history.md
Normal file
111
.doc/itop-version-history.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# iTop version history
|
||||
|
||||
```mermaid
|
||||
%%{init: { 'logLevel': 'debug', 'theme': 'base', 'themeVariables': {
|
||||
'git0': 'lawngreen',
|
||||
'git3': 'dodgerblue',
|
||||
'git4': 'grey',
|
||||
'git5': 'grey',
|
||||
'git6': 'grey',
|
||||
'git7': 'grey',
|
||||
'git8': 'grey'
|
||||
}, 'gitGraph': {'showBranches': true,'mainBranchName': 'develop','rotateCommitLabel': true}} }%%
|
||||
gitGraph
|
||||
commit id: "2016-07-06" tag: "2.3.0" type: HIGHLIGHT
|
||||
branch support/2.3 order: 900
|
||||
commit id: "2016-07-08" tag: "2.3.1"
|
||||
commit id: "2016-12-22" tag: "2.3.3"
|
||||
commit id: "2017-04-14" tag: "2.3.4"
|
||||
checkout develop
|
||||
commit id: "2017-07-12" tag: "2.4.0-beta" type: REVERSE
|
||||
commit id: "2017-11-16" tag: "2.4.0" type: HIGHLIGHT
|
||||
branch support/2.4 order: 890
|
||||
commit id: "2018-02-14" tag: "2.4.1"
|
||||
checkout develop
|
||||
commit id: "2018-04-25" tag: "2.5.0-beta" type: REVERSE
|
||||
checkout support/2.4
|
||||
commit id: "2018-06-14" tag: "2.4.2"
|
||||
checkout develop
|
||||
commit id: "2018-06-27" tag: "2.5.0" type: HIGHLIGHT
|
||||
branch support/2.5 order: 880
|
||||
checkout develop
|
||||
commit id: "2019-01-09" tag: "2.6.0" type: HIGHLIGHT
|
||||
branch support/2.6 order: 870
|
||||
commit id: "2019-03-28" tag: "2.6.1"
|
||||
checkout develop
|
||||
commit id: "2019-12-18" tag: "2.7.0-beta" type: REVERSE
|
||||
checkout support/2.5
|
||||
commit id: "2020-01-22" tag: "2.5.4"
|
||||
checkout support/2.6
|
||||
commit id: "2020-01-23" tag: "2.6.3"
|
||||
checkout develop
|
||||
commit id: "2020-01-29" tag: "2.7.0-beta2" type: REVERSE
|
||||
commit id: "2020-04-01" tag: "2.7.0-1" type: HIGHLIGHT
|
||||
checkout support/2.6
|
||||
commit id: "2020-04-22" tag: "2.6.4"
|
||||
checkout develop
|
||||
branch support/2.7 order: 860
|
||||
commit id: "2020-06-26" tag: "2.7.1"
|
||||
checkout support/2.7
|
||||
commit id: "2020-12-09" tag: "2.7.3"
|
||||
commit id: "2021-03-31" tag: "2.7.4"
|
||||
checkout develop
|
||||
commit id: "2021-04-06" tag: "3.0.0-beta" type: REVERSE
|
||||
checkout support/2.7
|
||||
commit id: "2021-07-05" tag: "2.7.5"
|
||||
checkout develop
|
||||
commit id: "2021-07-05." tag: "3.0.0-beta2" type: REVERSE
|
||||
checkout support/2.7
|
||||
commit id: "2021-12-17" tag: "2.7.6"
|
||||
checkout develop
|
||||
commit id: "2022-01-04" tag: "3.0.0" type: HIGHLIGHT
|
||||
branch support/3.0 order: 850
|
||||
commit id: "2022-04-08" tag: "3.0.1"
|
||||
checkout support/2.7
|
||||
commit id: "2022-07-11" tag: "2.7.7"
|
||||
checkout support/3.0
|
||||
commit id: "2022-09-12" tag: "3.0.2-1"
|
||||
checkout develop
|
||||
checkout support/2.7
|
||||
commit id: "2022-12-28" tag: "2.7.8"
|
||||
checkout support/3.0
|
||||
commit id: "2023-04-12" tag: "3.0.3"
|
||||
checkout develop
|
||||
commit id: "2023-06-19" tag: "3.1.0-beta" type: REVERSE
|
||||
commit id: "2023-07-26" tag: "3.1.0-1" type: HIGHLIGHT
|
||||
branch support/3.1 order: 840
|
||||
checkout support/3.1
|
||||
commit id: "2023-08-09" tag: "3.1.0-2"
|
||||
checkout support/2.7
|
||||
commit id: "2023-08-10" tag: "2.7.9"
|
||||
checkout support/3.1
|
||||
commit id: "2023-12-20" tag: "3.1.1"
|
||||
checkout develop
|
||||
commit id: "2024-01-15" tag: "Start 3.2" type: HIGHLIGHT
|
||||
branch support/3.2 order: 830
|
||||
checkout support/2.7
|
||||
commit id: "2024-01-17a" tag: "2.7.10"
|
||||
checkout support/3.0
|
||||
commit id: "2024-01-17b" tag: "3.0.4"
|
||||
checkout support/2.7
|
||||
commit id: "2024-09-28" tag: "2.7.11"
|
||||
checkout support/3.1
|
||||
commit id: "2024-09-27" tag: "3.1.2"
|
||||
checkout support/3.2
|
||||
commit id: "2024-06-25" tag: "3.2.0-beta1" type: REVERSE
|
||||
commit id: "2024-08-07" tag: "3.2.0"
|
||||
checkout support/2.7
|
||||
commit id: "2025-02-07a" tag: "2.7.12"
|
||||
checkout support/3.1
|
||||
commit id: "2025-02-07b" tag: "3.1.3"
|
||||
checkout support/3.2
|
||||
commit id: "2025-02-07c " tag: "3.2.1"
|
||||
commit id: "2025-03-31 " tag: "3.2.1-1"
|
||||
commit id: "2025-07-28 " tag: "3.2.2"
|
||||
checkout support/2.7
|
||||
commit id: "2025-09-25" tag: "2.7.13"
|
||||
checkout support/3.2
|
||||
commit id: "2026-04-27 " tag: "3.2.3"
|
||||
```
|
||||
|
||||
To learn more, check the [iTop community versions history on the official wiki](https://www.itophub.io/wiki/page?id=latest:release:start).
|
||||
@@ -897,6 +897,60 @@ abstract class AttributeDefinition
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the size of $value, expressed in the same unit as {@see static::GetMaxSize()} for this attribute class.
|
||||
*
|
||||
* Default unit is a number of **characters**, matching MySQL VARCHAR(M) semantics for VARCHAR-based attributes.
|
||||
* Byte-based attributes (e.g. {@see AttributeText}, stored as MySQL TEXT which is limited to 65535 **bytes**,
|
||||
* not characters) MUST override both this method and {@see TrimValue()} consistently.
|
||||
*
|
||||
* @param string|null $sValue
|
||||
*
|
||||
* @return int Size of $value in the unit of GetMaxSize() (characters by default)
|
||||
* @since 3.2.3-2 3.2.4 3.3.0 N°9759
|
||||
*/
|
||||
public function GetSize(?string $sValue)
|
||||
{
|
||||
// If the value is null, we return 0
|
||||
if ($sValue === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return mb_strlen($sValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to set a value that fits the attribute max size
|
||||
*
|
||||
* Truncation is performed in the same unit as GetMaxSize() / {@see GetSize()}: a number of **characters**
|
||||
* by default (VARCHAR-based attributes). When truncated, a " -truncated (N chars)" suffix is appended and
|
||||
* the returned value (suffix included) still fits within GetMaxSize().
|
||||
*
|
||||
* Default behavior is what DBObject::SetTrim used to do, now delegated to AttributeDefinition
|
||||
*
|
||||
* @param string|null $sValue
|
||||
*
|
||||
* @return string $sValue truncated so that it fits within {@see GetMaxSize()}.
|
||||
* @since 3.2.3-2 3.2.4 3.3.0 N°9759
|
||||
*/
|
||||
public function TrimValue(?string $sValue)
|
||||
{
|
||||
// If the value is null, we return an empty string
|
||||
if ($sValue === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iMaxSize = $this->GetMaxSize();
|
||||
$iLength = mb_strlen($sValue);
|
||||
if ($iMaxSize && ($iLength > $iMaxSize)) {
|
||||
$sMessage = " -truncated ($iLength chars)";
|
||||
|
||||
return mb_substr($sValue, 0, $iMaxSize - mb_strlen($sMessage)).$sMessage;
|
||||
}
|
||||
|
||||
return $sValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed|null
|
||||
* @deprecated never used
|
||||
@@ -4219,6 +4273,51 @@ class AttributeText extends AttributeString
|
||||
return "TEXT".CMDBSource::GetSqlStringColumnDefinition();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*
|
||||
* Unlike the default implementation, the size is expressed in **bytes**: MySQL TEXT columns are limited
|
||||
* in bytes (65535), not in characters, and {@see static::GetMaxSize()} for this class returns a number of bytes.
|
||||
*/
|
||||
public function GetSize(?string $sValue)
|
||||
{
|
||||
// If the value is null, we return 0
|
||||
if ($sValue === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strlen($sValue);
|
||||
}
|
||||
/**
|
||||
* @inheritDoc
|
||||
*
|
||||
* Truncation is performed on a **byte** budget (MySQL TEXT limit) without ever cutting through a multibyte
|
||||
* UTF-8 sequence: the returned value is always valid UTF-8 and never exceeds GetMaxSize() bytes,
|
||||
* truncation suffix included.
|
||||
*/
|
||||
public function TrimValue(?string $sValue)
|
||||
{
|
||||
// If the value is null, we return an empty string
|
||||
if ($sValue === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iMaxSize = $this->GetMaxSize();
|
||||
$iLength = strlen($sValue);
|
||||
$iLengthChar = mb_strlen($sValue);
|
||||
if ($iMaxSize && ($iLength > $iMaxSize)) {
|
||||
$sMessage = " -truncated ($iLengthChar chars)";
|
||||
$iTruncatedValueMaxSize = $iMaxSize - strlen($sMessage);
|
||||
// mb_strcut cuts on a byte budget but moves the cut point back to a character boundary,
|
||||
// so it never returns a broken multibyte sequence at the end of the value
|
||||
$sTruncatedValue = mb_strcut($sValue, 0, $iTruncatedValueMaxSize, 'UTF-8');
|
||||
|
||||
return $sTruncatedValue.$sMessage;
|
||||
}
|
||||
|
||||
return $sValue;
|
||||
}
|
||||
|
||||
public function GetSQLColumns($bFullSpec = false)
|
||||
{
|
||||
$aColumns = [];
|
||||
|
||||
@@ -732,13 +732,8 @@ abstract class DBObject implements iDisplay
|
||||
public function SetTrim($sAttCode, $sValue)
|
||||
{
|
||||
$oAttDef = MetaModel::GetAttributeDef(get_class($this), $sAttCode);
|
||||
$iMaxSize = $oAttDef->GetMaxSize();
|
||||
$sLength = mb_strlen($sValue);
|
||||
if ($iMaxSize && ($sLength > $iMaxSize)) {
|
||||
$sMessage = " -truncated ($sLength chars)";
|
||||
$sValue = mb_substr($sValue, 0, $iMaxSize - mb_strlen($sMessage)).$sMessage;
|
||||
}
|
||||
$this->Set($sAttCode, $sValue);
|
||||
|
||||
$this->Set($sAttCode, $oAttDef->TrimValue($sValue));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2040,7 +2035,7 @@ abstract class DBObject implements iDisplay
|
||||
}
|
||||
}
|
||||
if (!is_null($iMaxSize = $oAtt->GetMaxSize())) {
|
||||
$iLen = mb_strlen($toCheck);
|
||||
$iLen = $oAtt->GetSize($toCheck);
|
||||
if ($iLen > $iMaxSize) {
|
||||
return "String too long (found $iLen, limited to $iMaxSize)";
|
||||
}
|
||||
|
||||
@@ -1928,9 +1928,8 @@ class DBObjectSearch extends DBSearch
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @return DBObjectSearch
|
||||
*/
|
||||
protected function ApplyDataFilters(): DBObjectSearch
|
||||
protected function ApplyDataFilters(): DBSearch
|
||||
{
|
||||
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
|
||||
return $this;
|
||||
|
||||
@@ -676,9 +676,8 @@ class DBUnionSearch extends DBSearch
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @return DBUnionSearch
|
||||
*/
|
||||
protected function ApplyDataFilters(): DBUnionSearch
|
||||
protected function ApplyDataFilters(): DBSearch
|
||||
{
|
||||
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
|
||||
return $this;
|
||||
|
||||
@@ -340,13 +340,14 @@ class ormDocument
|
||||
* @param string $sContentDisposition Either 'inline' or 'attachment'
|
||||
* @param string $sSecretField The attcode of the field containing a "secret" to be provided in order to retrieve the file
|
||||
* @param string $sSecretValue The value of the secret to be compared with the value of the attribute $sSecretField
|
||||
* @param bool $bAllowAllData If true, no rights filtering is applied
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function DownloadDocument(WebPage $oPage, $sClass, $id, $sAttCode, $sContentDisposition = 'attachment', $sSecretField = null, $sSecretValue = null)
|
||||
public static function DownloadDocument(WebPage $oPage, $sClass, $id, $sAttCode, $sContentDisposition = 'attachment', $sSecretField = null, $sSecretValue = null, $bAllowAllData = false)
|
||||
{
|
||||
try {
|
||||
$oObj = MetaModel::GetObject($sClass, $id, false, false);
|
||||
$oObj = MetaModel::GetObject($sClass, $id, false, $bAllowAllData);
|
||||
if (!is_object($oObj)) {
|
||||
// If access to the document is not granted, check if the access to the host object is allowed
|
||||
$oObj = MetaModel::GetObject($sClass, $id, false, true);
|
||||
|
||||
@@ -153,8 +153,12 @@ class Extension
|
||||
return twig_array_filter($oTwigEnv, $array, $arrow);
|
||||
}, ['needs_environment' => true]);
|
||||
|
||||
// @since 3.3.0 N°8579
|
||||
// Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter
|
||||
/**
|
||||
* Filter to remove spaces between HTML tags, overwrite the deprecated core "spaceless" filter
|
||||
* Usage in twig: {% apply spaceless %}some html{% endapply %}
|
||||
*
|
||||
* @since 3.2.3 3.3.0 N°8579
|
||||
*/
|
||||
$aFilters[] = new TwigFilter('spaceless', function (?string $content) {
|
||||
return trim(preg_replace('/>\s+</', '><', $content ?? ''));
|
||||
}, ['is_safe' => ['html']]);
|
||||
|
||||
@@ -13,7 +13,6 @@ use Combodo\iTop\Application\UI\Base\Component\Button\ButtonUIBlockFactory;
|
||||
use Combodo\iTop\Application\UI\Base\Component\ButtonGroup\ButtonGroupUIBlockFactory;
|
||||
use Combodo\iTop\Application\UI\Base\Component\PopoverMenu\PopoverMenu;
|
||||
use Combodo\iTop\Application\UI\Base\Component\PopoverMenu\PopoverMenuItem\PopoverMenuItemFactory;
|
||||
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\CaseLogEntryForm\CaseLogEntryForm;
|
||||
use DBObject;
|
||||
use DBObjectSet;
|
||||
use Dict;
|
||||
@@ -39,7 +38,9 @@ class CaseLogEntryFormFactory
|
||||
->AddMainActionButtons(static::PrepareCancelButton());
|
||||
|
||||
$oSaveButton = static::PrepareSaveButton();
|
||||
$oTransitionsMenu = static::PrepareTransitionsSelectionPopoverMenu($oObject, $sCaseLogAttCode);
|
||||
$oTransitionsMenu = static::PrepareTransitionsSelectionPopoverMenu($oObject, $sCaseLogAttCode, $oCaseLogEntryForm->GetId());
|
||||
// Prevent popover menu from landing behind caselog editor
|
||||
$oTransitionsMenu->SetContainer(PopoverMenu::ENUM_CONTAINER_BODY);
|
||||
if (true === $oTransitionsMenu->HasItems()) {
|
||||
$oButtonGroup = ButtonGroupUIBlockFactory::MakeButtonWithOptionsMenu($oSaveButton, $oTransitionsMenu);
|
||||
$oCaseLogEntryForm->AddMainActionButtons($oButtonGroup);
|
||||
@@ -69,7 +70,16 @@ class CaseLogEntryFormFactory
|
||||
return $oButton;
|
||||
}
|
||||
|
||||
protected static function PrepareTransitionsSelectionPopoverMenu(DBObject $oObject, string $sCaseLogAttCode): PopoverMenu
|
||||
/**
|
||||
* @param DBObject $oObject
|
||||
* @param string $sCaseLogAttCode
|
||||
* @param string $sCaseLogEntryFormId
|
||||
* @since 3.2.3 Add mandatory $sCaseLogEntryFormId parameter
|
||||
* @return PopoverMenu
|
||||
* @throws \ArchivedObjectException
|
||||
* @throws \CoreException
|
||||
*/
|
||||
protected static function PrepareTransitionsSelectionPopoverMenu(DBObject $oObject, string $sCaseLogAttCode, string $sCaseLogEntryFormId): PopoverMenu
|
||||
{
|
||||
$sObjClass = get_class($oObject);
|
||||
|
||||
@@ -77,8 +87,6 @@ class CaseLogEntryFormFactory
|
||||
$sSectionId = 'send-actions';
|
||||
$oMenu->AddSection($sSectionId);
|
||||
|
||||
$sCaseLogEntryFormDataRole = CaseLogEntryForm::BLOCK_CODE;
|
||||
|
||||
// Note: This code is inspired from cmdbAbstract::DisplayModifyForm(), it might be better to factorize it
|
||||
$aTransitions = $oObject->EnumTransitions();
|
||||
if (!isset($aExtraParams['custom_operation']) && count($aTransitions)) {
|
||||
@@ -97,7 +105,7 @@ class CaseLogEntryFormFactory
|
||||
CaseLogEntryForm::BLOCK_CODE.'--add-action--'.$sCaseLogAttCode.'--stimulus--'.$sStimulusCode,
|
||||
Dict::Format('UI:Button:SendAnd', $aStimuli[$sStimulusCode]->GetLabel()),
|
||||
<<<JS
|
||||
$(this).closest('[data-role="{$sCaseLogEntryFormDataRole}"]').trigger('save_entry.caselog_entry_form.itop', {stimulus_code: '{$sStimulusCode}'});
|
||||
$('#$sCaseLogEntryFormId').trigger('save_entry.caselog_entry_form.itop', {stimulus_code: '{$sStimulusCode}'});
|
||||
JS
|
||||
)
|
||||
);
|
||||
|
||||
@@ -16,10 +16,12 @@ use Combodo\iTop\Application\UI\Base\Component\Title\TitleUIBlockFactory;
|
||||
use Combodo\iTop\Application\UI\Base\Component\Toolbar\ToolbarUIBlockFactory;
|
||||
use Combodo\iTop\Application\UI\Base\Layout\Object\ObjectSummary;
|
||||
use Combodo\iTop\Application\UI\Base\Layout\UIContentBlock;
|
||||
use Combodo\iTop\Application\WebPage\DownloadPage;
|
||||
use Combodo\iTop\Application\WebPage\iTopWebPage;
|
||||
use Combodo\iTop\Application\WebPage\JsonPage;
|
||||
use Combodo\iTop\Application\WebPage\JsonPPage;
|
||||
use Combodo\iTop\Controller\Notifications\NotificationsCenterController;
|
||||
use Combodo\iTop\Service\Notification\Event\EventNotificationNewsroomService;
|
||||
use Combodo\iTop\Service\Notification\NotificationsRepository;
|
||||
use Combodo\iTop\Service\Router\Router;
|
||||
use CoreException;
|
||||
@@ -27,6 +29,7 @@ use DBObjectSearch;
|
||||
use DBObjectSet;
|
||||
use Dict;
|
||||
use EventNotificationNewsroom;
|
||||
use Exception;
|
||||
use MetaModel;
|
||||
use SecurityException;
|
||||
use UserRights;
|
||||
@@ -376,9 +379,10 @@ JS
|
||||
$oEventBlock->SetCSSColorClass($sReadColor);
|
||||
$oEventBlock->SetSubTitle($sReadLabel);
|
||||
$oEventBlock->SetClassLabel('');
|
||||
/** @var \ormDocument $oImage */
|
||||
$oImage = $oEvent->Get('icon');
|
||||
if (!$oImage->IsEmpty()) {
|
||||
$sIconUrl = $oImage->GetDisplayURL(get_class($oEvent), $iEventId, 'icon');
|
||||
$sIconUrl = self::GetDisplayIconUrl($iEventId, $oImage->GetSignature());
|
||||
$oEventBlock->SetIcon($sIconUrl, Panel::ENUM_ICON_COVER_METHOD_COVER, true);
|
||||
}
|
||||
|
||||
@@ -542,7 +546,7 @@ $sMessage
|
||||
HTML;
|
||||
|
||||
$sIcon = $oMessage->Get('icon') !== null ?
|
||||
$oMessage->Get('icon')->GetDisplayURL(EventNotificationNewsroom::class, $oMessage->GetKey(), 'icon') :
|
||||
$this->GetDisplayIconUrl($oMessage->GetKey(), $oMessage->Get('icon')->GetSignature()) :
|
||||
Branding::GetCompactMainLogoAbsoluteUrl();
|
||||
$aMessages[] = [
|
||||
'id' => $oMessage->GetKey(),
|
||||
@@ -689,6 +693,35 @@ HTML;
|
||||
return $oPage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the icon of an EventNotificationNewsroom
|
||||
* (copy of ajax.render.php?operation=display_document but with the bAllowAllData parameter set to true in order to bypass the data access restrictions since the icon is not a critical information)
|
||||
* @return void
|
||||
* @throws \ConfigException
|
||||
* @throws \CoreException
|
||||
*/
|
||||
public function OperationViewIcon(): void
|
||||
{
|
||||
$sId = utils::ReadParam('id', '');
|
||||
if (!empty($sId)) {
|
||||
$oPage = new DownloadPage('');
|
||||
// X-Frame http header : set in page constructor, but we need to allow frame integration for this specific page
|
||||
// so we're resetting its value ! (see N°3416)
|
||||
$oPage->add_xframe_options('');
|
||||
$iCacheSec = (int)utils::ReadParam('cache', 0);
|
||||
$oPage->set_cache($iCacheSec);
|
||||
|
||||
// N°4129 - Prevent XSS attacks & other script executions
|
||||
if (utils::GetConfig()->Get('security.disable_inline_documents_sandbox') === false) {
|
||||
$oPage->add_header('Content-Security-Policy: sandbox;');
|
||||
}
|
||||
|
||||
if (EventNotificationNewsroomService::DownloadIcon($oPage, $sId, UserRights::GetContactId()) === true) {
|
||||
$oPage->Output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $sAction
|
||||
*
|
||||
@@ -781,4 +814,9 @@ HTML;
|
||||
|
||||
return $aReturnData;
|
||||
}
|
||||
|
||||
protected function GetDisplayIconUrl(string $sId, string $sSignature): string
|
||||
{
|
||||
return utils::GetAbsoluteUrlAppRoot()."pages/UI.php?route=itopnewsroom.view_icon&id=$sId&s=$sSignature&cache=86400";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ use Symfony\Component\CssSelector\Exception\ParseException;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\Transport;
|
||||
use Symfony\Component\Mailer\Mailer;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
|
||||
use Symfony\Component\Mime\Email as SymfonyEmail;
|
||||
use Symfony\Component\Mime\HtmlToTextConverter\DefaultHtmlToTextConverter;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\MixedPart;
|
||||
@@ -183,18 +185,7 @@ class EMailSymfony extends Email
|
||||
$sDsn = sprintf('smtp://%s%s@%s%s', $sDsnUser, $sDsnPassword, $sDsnPort, $sEncQuery);
|
||||
}
|
||||
|
||||
$oTransport = Transport::fromDsn($sDsn);
|
||||
|
||||
// Handle peer verification
|
||||
$oStream = $oTransport->getStream();
|
||||
$aOptions = $oStream->getStreamOptions();
|
||||
if (!$bVerifyPeer && array_key_exists('ssl', $aOptions)) {
|
||||
// Disable verification
|
||||
$aOptions['ssl']['verify_peer'] = false;
|
||||
$aOptions['ssl']['verify_peer_name'] = false;
|
||||
$aOptions['ssl']['allow_self_signed'] = true;
|
||||
}
|
||||
$oStream->setStreamOptions($aOptions);
|
||||
$oTransport = $this->CreateSmtpTransport($sDsn, $bVerifyPeer);
|
||||
|
||||
$oMailer = new Mailer($oTransport);
|
||||
break;
|
||||
@@ -260,6 +251,36 @@ class EMailSymfony extends Email
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and configure an SMTP transport from a DSN string.
|
||||
*
|
||||
* Extracted from {@see SendSynchronous} to make SSL option handling independently testable.
|
||||
* When $bVerifyPeer is false, the ssl stream context options must be written unconditionally:
|
||||
* with STARTTLS the connection starts unencrypted, so the 'ssl' key is absent from the stream
|
||||
* options at construction time and only used later when stream_socket_enable_crypto() is called.
|
||||
*
|
||||
* @param string $sDsn Full Symfony Mailer DSN (smtp:// or smtps://)
|
||||
* @param bool $bVerifyPeer Whether to verify the peer SSL certificate
|
||||
*
|
||||
* @return \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport
|
||||
*/
|
||||
protected function CreateSmtpTransport(string $sDsn, bool $bVerifyPeer): EsmtpTransport
|
||||
{
|
||||
/** @var EsmtpTransport $oTransport */
|
||||
$oTransport = Transport::fromDsn($sDsn);
|
||||
|
||||
$oStream = $oTransport->getStream();
|
||||
$aOptions = $oStream->getStreamOptions();
|
||||
if (!$bVerifyPeer) {
|
||||
$aOptions['ssl']['verify_peer'] = false;
|
||||
$aOptions['ssl']['verify_peer_name'] = false;
|
||||
$aOptions['ssl']['allow_self_signed'] = true;
|
||||
}
|
||||
$oStream->setStreamOptions($aOptions);
|
||||
|
||||
return $oTransport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprocess the body of the message (if it is an HTML message)
|
||||
* to replace the URL of images based on attachments by a link
|
||||
@@ -416,13 +437,13 @@ class EMailSymfony extends Email
|
||||
|
||||
$this->m_aData['body'] = ['body' => $sBody, 'mimeType' => $sMimeType];
|
||||
|
||||
$oTextPart = new TextPart(strip_tags($sBody), 'utf-8', 'plain', 'base64');
|
||||
|
||||
// Embed inline images and store them in attachments (so BuildSymfonyMessageFromInternal can pick them)
|
||||
if ($sPrimaryMimeType === 'text/html') {
|
||||
$aAdditionalParts = $this->EmbedInlineImages($sBody);
|
||||
$oTextPart = new TextPart((new DefaultHtmlToTextConverter())->convert($sBody, 'utf-8'), 'utf-8', 'plain', 'base64');
|
||||
$oHtmlPart = new TextPart($sBody, 'utf-8', 'html', 'base64');
|
||||
$oAlternativePart = new AlternativePart($oHtmlPart, $oTextPart);
|
||||
// It's important de order parts from least prefered to most prefered as per RFC 2046 {@see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4}
|
||||
$oAlternativePart = new AlternativePart($oTextPart, $oHtmlPart);
|
||||
// Default root part is the HTML body
|
||||
$oRootPart = $oAlternativePart;
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace Combodo\iTop\Service\Notification\Event;
|
||||
|
||||
use Action;
|
||||
use Combodo\iTop\Application\Branding;
|
||||
use Combodo\iTop\Application\WebPage\WebPage;
|
||||
use EventNotificationNewsroom;
|
||||
use MetaModel;
|
||||
use ormDocument;
|
||||
use utils;
|
||||
|
||||
/**
|
||||
@@ -70,4 +72,31 @@ class EventNotificationNewsroomService
|
||||
|
||||
return $oEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Combodo\iTop\Application\WebPage\WebPage $oPage
|
||||
* @param string $sId
|
||||
* @param int $iContactId
|
||||
*
|
||||
* @return bool Returns true if the download has been launched, false otherwise (e.g. if the event doesn't exist or doesn't belong to the current user)
|
||||
* @throws \ArchivedObjectException
|
||||
* @throws \CoreException
|
||||
*/
|
||||
public static function DownloadIcon(WebPage $oPage, string $sId, int $iContactId): bool
|
||||
{
|
||||
$oEvent = MetaModel::GetObject(EventNotificationNewsroom::class, $sId, false, true);
|
||||
if (($oEvent !== null) && ($oEvent->Get('contact_id') === $iContactId)) {
|
||||
ormDocument::DownloadDocument(
|
||||
$oPage,
|
||||
EventNotificationNewsroom::class,
|
||||
$sId,
|
||||
'icon',
|
||||
ormDocument::ENUM_CONTENT_DISPOSITION_INLINE,
|
||||
bAllowAllData: true
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ use Team;
|
||||
use User;
|
||||
use UserRequest;
|
||||
use UserRights;
|
||||
use utils;
|
||||
|
||||
/**
|
||||
* @group specificOrgInSampleData
|
||||
@@ -1297,19 +1296,21 @@ class DBObjectTest extends ItopDataTestCase
|
||||
{
|
||||
return [
|
||||
// UserRequest.title is an AttributeString (maxsize = 255)
|
||||
'title 250 chars' => ['title', 250],
|
||||
'title 254 chars' => ['title', 254],
|
||||
'title 255 chars' => ['title', 255],
|
||||
'title 256 chars' => ['title', 256],
|
||||
'title 300 chars' => ['title', 300],
|
||||
'title 250 chars' => ['title', 250, 250, true],
|
||||
'title 254 chars' => ['title', 254, 254, true],
|
||||
'title 255 chars' => ['title', 255, 255, true],
|
||||
'title 256 chars' => ['title', 256, 255, false],
|
||||
'title 300 chars' => ['title', 300, 255, false],
|
||||
|
||||
// UserRequest.pending_reason is an AttributeText (maxsize=65535) with format=text
|
||||
'pending_reason 250 chars' => ['pending_reason', 250],
|
||||
'pending_reason 60000 chars' => ['pending_reason', 60000],
|
||||
'pending_reason 65534 chars' => ['pending_reason', 65534],
|
||||
'pending_reason 65535 chars' => ['pending_reason', 65535],
|
||||
'pending_reason 65536 chars' => ['pending_reason', 65536],
|
||||
'pending_reason 70000 chars' => ['pending_reason', 70000],
|
||||
'pending_reason 250 chars' => ['pending_reason', 250, 250, true],
|
||||
'pending_reason 65534 chars' => ['pending_reason', 65534, 16403, false],
|
||||
'pending_reason 65535 chars' => ['pending_reason', 65535, 16403, false],
|
||||
'pending_reason 65536 chars' => ['pending_reason', 65536, 16403, false],
|
||||
'pending_reason 16385 chars' => ['pending_reason', 16385, 16403, false],
|
||||
'pending_reason 16384 chars' => ['pending_reason', 16384, 16384, true],
|
||||
'pending_reason 16383 chars' => ['pending_reason', 16383, 16383, true],
|
||||
'pending_reason 16382 chars' => ['pending_reason', 16382, 16382, true],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1322,7 +1323,7 @@ class DBObjectTest extends ItopDataTestCase
|
||||
*
|
||||
* @since 3.1.2 N°3448 - Framework field size check not correctly implemented for multi-bytes languages/strings
|
||||
*/
|
||||
public function testCheckLongValueInAttribute(string $sAttrCode, int $iValueLength)
|
||||
public function testCheckLongValueInAttribute(string $sAttrCode, int $iValueLength, int $iExpectedLength, bool $bIsValueToSetBelowAttrMaxSize): void
|
||||
{
|
||||
$sPrefix = 'a'; // just a small prefix so that the emoji bytes won't have a power of 2 (we want a non even value)
|
||||
$sEmojiToRepeat = '😎'; // this emoji is 4 bytes long
|
||||
@@ -1343,17 +1344,18 @@ class DBObjectTest extends ItopDataTestCase
|
||||
|
||||
$oAttDef = MetaModel::GetAttributeDef(UserRequest::class, $sAttrCode);
|
||||
$iAttrMaxSize = $oAttDef->GetMaxSize();
|
||||
$bIsValueToSetBelowAttrMaxSize = ($iValueLength <= $iAttrMaxSize);
|
||||
$bExpectedStatus = ($oAttDef->GetSize($sValueToSet) <= $iAttrMaxSize);
|
||||
$this->assertSame($bExpectedStatus, $bIsValueToSetBelowAttrMaxSize, 'The data provider must stay aligned with the attribute max size logic.');
|
||||
/** @noinspection PhpUnusedLocalVariableInspection */
|
||||
[$bCheckStatus, $aCheckIssues, $bSecurityIssue] = $oTicket->CheckToWrite();
|
||||
$this->assertEquals($bIsValueToSetBelowAttrMaxSize, $bCheckStatus, "CheckResult result:".var_export($aCheckIssues, true));
|
||||
|
||||
$oTicket->SetTrim($sAttrCode, $sValueToSet);
|
||||
$sValueInObject = $oTicket->Get($sAttrCode);
|
||||
$this->assertEquals($iExpectedLength, mb_strlen($sValueInObject), 'Should match expected resulting value length.');
|
||||
if ($bIsValueToSetBelowAttrMaxSize) {
|
||||
$this->assertEquals($sValueToSet, $sValueInObject, 'Should not alter string that is already shorter than attribute max length');
|
||||
} else {
|
||||
$this->assertEquals($iAttrMaxSize, mb_strlen($sValueInObject), 'Should truncate at the same length than attribute max length');
|
||||
$sLastCharsOfValueInObject = mb_substr($sValueInObject, -30);
|
||||
$this->assertStringContainsString(' -truncated', $sLastCharsOfValueInObject, 'Should end with "truncated" comment');
|
||||
}
|
||||
@@ -1386,6 +1388,61 @@ class DBObjectTest extends ItopDataTestCase
|
||||
$this->assertEquals($sResult, $oOrganisation->Get('name'), 'SetTrim must limit string to 255 characters');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that DBObject::SetTrim doesn't cut through multibytes characters
|
||||
*
|
||||
* @covers DBObject::SetTrim
|
||||
* @dataProvider SetTrimAttributeTextProvider
|
||||
*/
|
||||
public function testSetTrimOnAttributeTextKeepsUtf8Validity(string $sChar, int $iRepeatCount, bool $bExpectExactByteFill): void
|
||||
{
|
||||
$oTicket = MetaModel::NewObject('UserRequest', [
|
||||
'ref' => 'Test Ticket',
|
||||
'title' => 'Create OK',
|
||||
'description' => 'Create OK',
|
||||
'caller_id' => 15,
|
||||
'org_id' => 3,
|
||||
]);
|
||||
|
||||
$sValueToSet = str_repeat($sChar, $iRepeatCount);
|
||||
$oTicket->SetTrim('pending_reason', $sValueToSet);
|
||||
$sValueInObject = $oTicket->Get('pending_reason');
|
||||
|
||||
$oAttDef = MetaModel::GetAttributeDef('UserRequest', 'pending_reason');
|
||||
$iAttrMaxSize = $oAttDef->GetMaxSize();
|
||||
$iOriginalCharLength = mb_strlen($sValueToSet);
|
||||
$sMessage = " -truncated ($iOriginalCharLength chars)";
|
||||
|
||||
$this->assertStringEndsWith($sMessage, $sValueInObject, 'Trimmed value should keep the expected truncation suffix.');
|
||||
$this->assertTrue(mb_check_encoding($sValueInObject, 'UTF-8'), 'Trimmed value should stay valid UTF-8.');
|
||||
$this->assertLessThanOrEqual($iAttrMaxSize, strlen($sValueInObject), 'Trimmed value should never exceed attribute byte max size.');
|
||||
|
||||
if ($bExpectExactByteFill) {
|
||||
$this->assertSame($iAttrMaxSize, strlen($sValueInObject), 'When byte cut lands on a character boundary, SetTrim should use all available bytes.');
|
||||
}
|
||||
}
|
||||
|
||||
public function SetTrimAttributeTextProvider()
|
||||
{
|
||||
return [
|
||||
// 2-byte UTF-8 chars: truncation payload size is byte-aligned and should fill the max size exactly.
|
||||
'pending_reason 2-byte chars on byte boundary' => ["\xC3\xA9", 32768, true],
|
||||
// 4-byte UTF-8 chars: truncation payload size is not byte-aligned and must backtrack to valid UTF-8.
|
||||
'pending_reason 4-byte chars with mid-character byte cut' => ['💃', 16385, false],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers DBObject::SetTrim
|
||||
*/
|
||||
public function testSetTrimOnNonStringAttributeDoesNotTrim()
|
||||
{
|
||||
$oTicket = MetaModel::NewObject(UserRequest::class);
|
||||
$oTicket->SetTrim('caller_id', '15');
|
||||
|
||||
$this->assertEquals(15, $oTicket->Get('caller_id'), 'SetTrim should keep non-string attributes untouched before regular Set conversion');
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers DBObject::SetComputedDate
|
||||
* @return void
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* @copyright Copyright (C) 2010-2026 Combodo SAS
|
||||
* @license http://opensource.org/licenses/AGPL-3.0
|
||||
*/
|
||||
|
||||
namespace Combodo\iTop\Test\UnitTest\Core;
|
||||
|
||||
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
|
||||
use DBObjectSearch;
|
||||
use DBUnionSearch;
|
||||
use UserRights;
|
||||
|
||||
class DBSearchApplyDataFiltersTest extends ItopDataTestCase
|
||||
{
|
||||
public const CREATE_TEST_ORG = true;
|
||||
|
||||
protected string $sOriginalUserRightsSelectModuleClass = '';
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Backup original UserRights select module as it will changed in some tests
|
||||
$this->sOriginalUserRightsSelectModuleClass = get_class(UserRights::GetModuleInstance());
|
||||
|
||||
$this->RequireOnceUnitTestFile('Fixtures/N9687_CustomGetSelectFilterClass.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function tearDown(): void
|
||||
{
|
||||
// Restore original UserRights select module to not interfere with next tests
|
||||
UserRights::SelectModule($this->sOriginalUserRightsSelectModuleClass);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testApplyDataFiltersOnDBObjectSearchShouldAcceptGetSelectFilterClassReturningDBUnionSearch()
|
||||
{
|
||||
// Use custom select filter that returns a DBUnionSearch
|
||||
$sPreviousSelectModuleClass = get_class(UserRights::GetModuleInstance());
|
||||
UserRights::SelectModule('\\Combodo\\iTop\\Test\\UnitTest\\Core\\Fixtures\\N9687_CustomGetSelectFilterClass');
|
||||
|
||||
// Create a user and login, otherwise the select filter won't apply
|
||||
self::CreateUser('test_dbsearch_applydatafilters', 3);
|
||||
UserRights::Login('test_dbsearch_applydatafilters');
|
||||
|
||||
// Create a person
|
||||
$oCreatedPerson = $this->CreatePerson(microtime());
|
||||
|
||||
// Try to retrieve it using the select filter
|
||||
$oSearch = DBObjectSearch::FromOQL("SELECT Person WHERE id = {$oCreatedPerson->GetKey()}");
|
||||
$oFilteredSearch = $this->InvokeNonPublicMethod(DBObjectSearch::class, 'ApplyDataFilters', $oSearch);
|
||||
|
||||
// Restore original select module to not interfere with next tests
|
||||
UserRights::SelectModule($sPreviousSelectModuleClass);
|
||||
|
||||
$this->assertEquals(DBUnionSearch::class, get_class($oFilteredSearch), "DBObjectSearch::ApplyDataFilters() should be able to return a \DBUnionSearch");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* @copyright Copyright (C) 2010-2026 Combodo SAS
|
||||
* @license http://opensource.org/licenses/AGPL-3.0
|
||||
*/
|
||||
|
||||
namespace Combodo\iTop\Test\UnitTest\Core\Fixtures;
|
||||
|
||||
use DBObjectSearch;
|
||||
use DBUnionSearch;
|
||||
use UserRightsProfile;
|
||||
|
||||
class N9687_CustomGetSelectFilterClass extends UserRightsProfile
|
||||
{
|
||||
public function GetSelectFilter($oUser, $sClass, $aSettings = [])
|
||||
{
|
||||
// We just need the method to return an union search
|
||||
return new DBUnionSearch([
|
||||
DBObjectSearch::FromOQL("SELECT $sClass WHERE 1!=2"),
|
||||
DBObjectSearch::FromOQL("SELECT $sClass WHERE 1=1"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,21 @@ class ormDocumentTest extends ItopDataTestCase
|
||||
$this->assertStringNotContainsString('the object does not exist or you are not allowed to view it', $sAllowedHtml, 'Unexpected error message when rights are sufficient.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider DownloadDocumentRightsProvider
|
||||
*/
|
||||
public function testAllowsDownloadingDocumentWhenBypassingRightsChecksWithAllowAllData(string $sTargetClass, string $sAttCode, string $sData, string $sFileName, ?string $sHostClass)
|
||||
{
|
||||
$iDeniedDocumentId = $this->CreateDownloadTargetInOrg($sTargetClass, $sAttCode, $this->iOrgDifferentFromUser, $sData, $sFileName, $sHostClass);
|
||||
|
||||
$oPageAllowed = new CaptureWebPage();
|
||||
ormDocument::DownloadDocument($oPageAllowed, $sTargetClass, $iDeniedDocumentId, $sAttCode, ormDocument::ENUM_CONTENT_DISPOSITION_INLINE, bAllowAllData: true);
|
||||
$sAllowedHtml = $oPageAllowed->GetHtml();
|
||||
|
||||
$this->assertStringContainsString($sData, $sAllowedHtml, 'Expected file data present when bypassing rights checks.');
|
||||
$this->assertStringNotContainsString("Invalid id ($iDeniedDocumentId) for class '$sTargetClass' - the object does not exist or you are not allowed to view it", $sAllowedHtml, 'Unexpected invalid id error message when bypassing rights checks.');
|
||||
}
|
||||
|
||||
public function DownloadDocumentRightsProvider(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace Combodo\iTop\Test\UnitTest\Service\Notification\Event;
|
||||
|
||||
use Action;
|
||||
use ActionNewsroom;
|
||||
use Combodo\iTop\Application\WebPage\CaptureWebPage;
|
||||
use Combodo\iTop\Service\Notification\Event\EventNotificationNewsroomService;
|
||||
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
|
||||
use Contact;
|
||||
use EventNotificationNewsroom;
|
||||
use Person;
|
||||
use Ticket;
|
||||
use Trigger;
|
||||
use TriggerOnObjectMention;
|
||||
use UserRequest;
|
||||
use UserRights;
|
||||
|
||||
class EventNotificationNewsroomServiceTest extends ItopDataTestCase
|
||||
{
|
||||
public const CREATE_TEST_ORG = true;
|
||||
|
||||
private Contact $oContact;
|
||||
private Trigger $oTrigger;
|
||||
private Action $oAction;
|
||||
private Ticket $oTicket;
|
||||
private EventNotificationNewsroom $oEvent;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
/** @var Contact $oContact */
|
||||
$oContact = $this->createObject(Person::class, [
|
||||
'name' => 'Khalo',
|
||||
'first_name' => 'Frida',
|
||||
'org_id' => $this->getTestOrgId(),
|
||||
]);
|
||||
$this->oContact = $oContact;
|
||||
|
||||
/** @var Trigger $oTrigger */
|
||||
$oTrigger = $this->createObject(TriggerOnObjectMention::class, [
|
||||
'description' => 'Person mentioned on Ticket',
|
||||
'target_class' => 'Ticket',
|
||||
]);
|
||||
$this->oTrigger = $oTrigger;
|
||||
|
||||
/** @var Action $oAction */
|
||||
$oAction = $this->createObject(ActionNewsroom::class, [
|
||||
'name' => 'Notification to persons mentioned in logs',
|
||||
'status' => 'enabled',
|
||||
'title' => '$this->friendlyname$',
|
||||
'message' => 'You have been mentioned by $current_contact->friendlyname$',
|
||||
'recipients' => 'SELECT Person WHERE id = :mentioned->id',
|
||||
]);
|
||||
$this->oAction = $oAction;
|
||||
|
||||
/** @var Ticket $oTicket */
|
||||
$oTicket = $this->createObject(UserRequest::class, [
|
||||
'org_id' => $this->getTestOrgId(),
|
||||
'title' => 'Houston, got a problem',
|
||||
'description' => 'Test description',
|
||||
]);
|
||||
$this->oTicket = $oTicket;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
$this->oEvent->DBDelete();
|
||||
}
|
||||
|
||||
public function testDownloadIsTriggeredWhenDownloaderIsNotificationRecipient(): void
|
||||
{
|
||||
$this->oEvent = EventNotificationNewsroomService::MakeEventFromAction(
|
||||
oAction: $this->oAction,
|
||||
iContactId: $this->oContact->GetKey(),
|
||||
iTriggerId: $this->oTrigger->GetKey(),
|
||||
sMessage: 'Test message',
|
||||
sTitle: 'Test event',
|
||||
sUrl: 'https://localhost/itop/pages/UI.php?operation=details&class=UserRequest&id=1',
|
||||
iObjectId: $this->oTicket->GetKey(),
|
||||
sObjectClass: UserRequest::class,
|
||||
);
|
||||
$this->oEvent->DBInsert();
|
||||
|
||||
$oPage = new CaptureWebPage();
|
||||
$bDownloadIcon = EventNotificationNewsroomService::DownloadIcon($oPage, $this->oEvent->GetKey(), $this->oContact->GetKey());
|
||||
$sHtml = $oPage->GetHtml();
|
||||
|
||||
$this->assertTrue($bDownloadIcon);
|
||||
$this->assertNotEquals('', $sHtml);
|
||||
}
|
||||
|
||||
public function testDownloadIsNotTriggeredWhenDownloaderIsNotNotificationRecipient(): void
|
||||
{
|
||||
$oContact = $this->createObject(Person::class, [
|
||||
'name' => 'Doe',
|
||||
'first_name' => 'John',
|
||||
'org_id' => $this->getTestOrgId(),
|
||||
]);
|
||||
$this->oEvent = EventNotificationNewsroomService::MakeEventFromAction(
|
||||
oAction: $this->oAction,
|
||||
iContactId: $oContact->GetKey(),
|
||||
iTriggerId: $this->oTrigger->GetKey(),
|
||||
sMessage: 'Test message',
|
||||
sTitle: 'Test event',
|
||||
sUrl: 'https://localhost/itop/pages/UI.php?operation=details&class=UserRequest&id=1',
|
||||
iObjectId: $this->oTicket->GetKey(),
|
||||
sObjectClass: UserRequest::class,
|
||||
);
|
||||
$this->oEvent->DBInsert();
|
||||
|
||||
$oPage = new CaptureWebPage();
|
||||
$bDownloadIcon = EventNotificationNewsroomService::DownloadIcon($oPage, $this->oEvent->GetKey(), $this->oContact->GetKey());
|
||||
$sHtml = $oPage->GetHtml();
|
||||
|
||||
$this->assertFalse($bDownloadIcon);
|
||||
$this->assertEquals('', $sHtml);
|
||||
}
|
||||
|
||||
public function testDownloadIconIsTriggeredEvenWhenUserCannotReadIconAttribute(): void
|
||||
{
|
||||
// Create a user with Support Agent Profile
|
||||
$sLogin = uniqid('EventNotificationNewsroomServiceTest');
|
||||
$oUser = $this->CreateContactlessUser($sLogin, self::$aURP_Profiles['Support Agent'], '1234@Abcdefg');
|
||||
$oUser->Set('contactid', $this->oContact->GetKey());
|
||||
UserRights::Login($sLogin);
|
||||
|
||||
$this->oEvent = EventNotificationNewsroomService::MakeEventFromAction(
|
||||
oAction: $this->oAction,
|
||||
iContactId: $this->oContact->GetKey(),
|
||||
iTriggerId: $this->oTrigger->GetKey(),
|
||||
sMessage: 'Test message',
|
||||
sTitle: 'Test event',
|
||||
sUrl: 'https://localhost/itop/pages/UI.php?operation=details&class=UserRequest&id=1',
|
||||
iObjectId: $this->oTicket->GetKey(),
|
||||
sObjectClass: UserRequest::class,
|
||||
);
|
||||
$this->oEvent->DBInsert();
|
||||
|
||||
$iURValue = UserRights::IsActionAllowedOnAttribute(EventNotificationNewsroom::class, 'icon', UR_ACTION_READ, $this->oEvent, $oUser);
|
||||
$this->assertEquals(UR_ALLOWED_NO, $iURValue);
|
||||
|
||||
$oPage = new CaptureWebPage();
|
||||
$bDownloadIcon = EventNotificationNewsroomService::DownloadIcon($oPage, $this->oEvent->GetKey(), $this->oContact->GetKey());
|
||||
$sHtml = $oPage->GetHtml();
|
||||
|
||||
$this->assertTrue($bDownloadIcon);
|
||||
$this->assertNotEquals('', $sHtml);
|
||||
$this->assertStringNotContainsString('the object does not exist or you are not allowed to view it', $sHtml);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Combodo\iTop\Core\Email\EMailSymfony;
|
||||
use Combodo\iTop\Test\UnitTest\ItopTestCase;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
|
||||
use Symfony\Component\Mime\Part\DataPart;
|
||||
use Symfony\Component\Mime\Part\Multipart\AlternativePart;
|
||||
use Symfony\Component\Mime\Part\Multipart\RelatedPart;
|
||||
use Symfony\Component\Mime\Part\TextPart;
|
||||
|
||||
class EmailSymfonyTest extends ItopTestCase
|
||||
{
|
||||
@@ -135,4 +141,221 @@ HTML;
|
||||
|
||||
$this->assertSame($sExpectedBody, $sActualBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parts of the AlternativePart produced by SetBody() for an HTML email.
|
||||
*
|
||||
* Handles both the simple case (AlternativePart at root) and the inline-images case
|
||||
* where the root is a RelatedPart whose first child is the AlternativePart.
|
||||
*
|
||||
* @return AbstractPart[]
|
||||
*/
|
||||
private function GetAlternativePartsFromHtmlEmail(EMailSymfony $oEmail): array
|
||||
{
|
||||
$oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage');
|
||||
$oBody = $oSymfonyMessage->getBody();
|
||||
|
||||
// With inline images the root is a RelatedPart; the AlternativePart is its first child.
|
||||
if ($oBody instanceof RelatedPart) {
|
||||
$oBody = $oBody->getParts()[0];
|
||||
}
|
||||
|
||||
$this->assertInstanceOf(AlternativePart::class, $oBody, 'Body should be a multipart/alternative for HTML emails');
|
||||
|
||||
return $oBody->getParts();
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 2046 §5.1.4: parts in multipart/alternative must be ordered from least to most preferred.
|
||||
* Email clients display the last part they support, so text/plain must come first and text/html last.
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4
|
||||
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
|
||||
* @since N°9574
|
||||
*/
|
||||
public function testSetBodyAlternativePartOrderForHtmlEmailIsPlainThenHtml(): void
|
||||
{
|
||||
$oEmail = new EMailSymfony();
|
||||
$oEmail->SetBody('<p>Hello there!</p>', 'text/html');
|
||||
|
||||
[$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail);
|
||||
|
||||
$this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)');
|
||||
$this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSetBodyPlainTextDoesNotContainCss
|
||||
*
|
||||
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
|
||||
* @since N°9574
|
||||
*/
|
||||
public function testSetBodyPlainTextDoesNotContainCss(string $sHtml, ?string $sCustomStyles): void
|
||||
{
|
||||
$oEmail = new EMailSymfony();
|
||||
$oEmail->SetBody($sHtml, 'text/html', $sCustomStyles);
|
||||
|
||||
// We locate the plain text part by subtype to be order-agnostic and isolate this assertion from the order bug.
|
||||
$aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail);
|
||||
$oPlainPart = null;
|
||||
foreach ($aParts as $oPart) {
|
||||
if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'plain') {
|
||||
$oPlainPart = $oPart;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($oPlainPart, 'No text/plain part found in the message');
|
||||
|
||||
$sPlainText = $oPlainPart->getBody();
|
||||
|
||||
$this->assertStringNotContainsString('<style>', $sPlainText, 'Style tag must not appear in plain text');
|
||||
$this->assertStringNotContainsString('color:', $sPlainText, 'CSS color rule must not appear in plain text');
|
||||
$this->assertStringNotContainsString('font-size:', $sPlainText, 'CSS font-size rule must not appear in plain text');
|
||||
$this->assertStringNotContainsString('@media', $sPlainText, 'CSS @media rule must not appear in plain text');
|
||||
$this->assertStringContainsString('Hello there!', $sPlainText, 'Actual content must be preserved in plain text');
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTML part must contain the body content and the CSS inlined by Emogrifier.
|
||||
* This guards against regressions where the wrong body (e.g. the plain-text version)
|
||||
* would end up in the HTML part.
|
||||
*
|
||||
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
|
||||
* @since N°9574
|
||||
*/
|
||||
public function testSetBodyHtmlPartContainsBodyAndInlinedCss(): void
|
||||
{
|
||||
$oEmail = new EMailSymfony();
|
||||
$oEmail->SetBody('<html><body><p>Hello there!</p></body></html>', 'text/html', 'p { color: red; }');
|
||||
|
||||
$aParts = $this->GetAlternativePartsFromHtmlEmail($oEmail);
|
||||
|
||||
$oHtmlPart = null;
|
||||
foreach ($aParts as $oPart) {
|
||||
if ($oPart instanceof TextPart && $oPart->getMediaSubtype() === 'html') {
|
||||
$oHtmlPart = $oPart;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($oHtmlPart, 'No text/html part found in the message');
|
||||
|
||||
$sHtmlContent = $oHtmlPart->getBody();
|
||||
$this->assertStringContainsString('Hello there!', $sHtmlContent, 'HTML part must preserve the original text content');
|
||||
$this->assertStringContainsString('color: red', $sHtmlContent, 'HTML part must contain the CSS inlined by Emogrifier');
|
||||
}
|
||||
|
||||
/**
|
||||
* With inline images, SetBody() wraps the AlternativePart in a RelatedPart.
|
||||
* The AlternativePart must still be correctly ordered (plain first, HTML last)
|
||||
* and the plain-text part must not contain CSS.
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4
|
||||
* @covers \Combodo\iTop\Core\Email\EmailSymfony::SetBody()
|
||||
* @since N°9574
|
||||
*/
|
||||
public function testSetBodyWithInlineImagesHasCorrectPartStructure(): void
|
||||
{
|
||||
// Anonymous subclass so we can inject a fake inline image part without a real inline image in DB
|
||||
$oEmail = new class () extends EMailSymfony {
|
||||
protected function EmbedInlineImages(string &$sBody): array
|
||||
{
|
||||
return [new DataPart('fake-image-data', 'image.png', 'image/png')];
|
||||
}
|
||||
};
|
||||
$oEmail->SetBody('<html><head><style>p { color: red; }</style></head><body><p>Hello there!</p></body></html>', 'text/html');
|
||||
|
||||
$oSymfonyMessage = $this->GetNonPublicProperty($oEmail, 'm_oMessage');
|
||||
$oBody = $oSymfonyMessage->getBody();
|
||||
|
||||
// Root must be a RelatedPart when inline images are present
|
||||
$this->assertInstanceOf(RelatedPart::class, $oBody, 'Root part must be multipart/related when inline images are present');
|
||||
|
||||
// The AlternativePart must be the first child of the RelatedPart
|
||||
$aRelatedParts = $oBody->getParts();
|
||||
$this->assertInstanceOf(AlternativePart::class, $aRelatedParts[0], 'First child of RelatedPart must be the AlternativePart');
|
||||
|
||||
// Order and CSS checks are delegated to the shared helper, which now handles RelatedPart
|
||||
[$oFirstPart, $oSecondPart] = $this->GetAlternativePartsFromHtmlEmail($oEmail);
|
||||
$this->assertSame('plain', $oFirstPart->getMediaSubtype(), 'First part must be text/plain (least preferred per RFC 2046)');
|
||||
$this->assertSame('html', $oSecondPart->getMediaSubtype(), 'Last part must be text/html (most preferred per RFC 2046)');
|
||||
}
|
||||
|
||||
public function provideSetBodyPlainTextDoesNotContainCss(): array
|
||||
{
|
||||
$sCustomStyles = 'p { color: blue; font-size: 14px; }';
|
||||
|
||||
return [
|
||||
'<style> tag in HTML, no custom styles' => [
|
||||
'<html><head><style>body { color: red; font-size: 12px; } @media print { p { color: black; } }</style></head><body><p>Hello there!</p></body></html>',
|
||||
null,
|
||||
],
|
||||
'<style> tag in HTML with custom styles' => [
|
||||
'<html><head><style>body { color: red; font-size: 12px; } @media print { p { color: black; } }</style></head><body><p>Hello there!</p></body></html>',
|
||||
$sCustomStyles,
|
||||
],
|
||||
'custom styles only, no <style> tag' => [
|
||||
'<html><body><p>Hello there!</p></body></html>',
|
||||
$sCustomStyles,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideCreateSmtpTransportSslOptions
|
||||
*/
|
||||
public function testCreateSmtpTransportSslOptions(string $sDsn, bool $bVerifyPeer, array $aExpectedSslOptions): void
|
||||
{
|
||||
$oEmail = new EMailSymfony();
|
||||
/** @var EsmtpTransport $oTransport */
|
||||
$oTransport = $this->InvokeNonPublicMethod(EMailSymfony::class, 'CreateSmtpTransport', $oEmail, [$sDsn, $bVerifyPeer]);
|
||||
|
||||
$aActualSslOptions = $oTransport->getStream()->getStreamOptions()['ssl'] ?? [];
|
||||
|
||||
$this->assertSame($aExpectedSslOptions, $aActualSslOptions);
|
||||
}
|
||||
|
||||
public function provideCreateSmtpTransportSslOptions(): array
|
||||
{
|
||||
$aDisabledVerification = [
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true,
|
||||
];
|
||||
|
||||
return [
|
||||
// Regression scenario (N°9584): STARTTLS starts the connection unencrypted, so the 'ssl' key
|
||||
// is absent from stream options at construction time. verify_peer=false must still be applied.
|
||||
'STARTTLS, verify_peer=false' => [
|
||||
'smtp://localhost:587?encryption=starttls',
|
||||
false,
|
||||
$aDisabledVerification,
|
||||
],
|
||||
'implicit TLS (smtps), verify_peer=false' => [
|
||||
'smtps://localhost:465',
|
||||
false,
|
||||
$aDisabledVerification,
|
||||
],
|
||||
'plain SMTP, verify_peer=false' => [
|
||||
'smtp://localhost:25',
|
||||
false,
|
||||
$aDisabledVerification,
|
||||
],
|
||||
// Default behavior: verify_peer=true must leave stream options untouched (empty).
|
||||
'STARTTLS, verify_peer=true (default)' => [
|
||||
'smtp://localhost:587?encryption=starttls',
|
||||
true,
|
||||
[],
|
||||
],
|
||||
'implicit TLS (smtps), verify_peer=true (default)' => [
|
||||
'smtps://localhost:465',
|
||||
true,
|
||||
[],
|
||||
],
|
||||
'plain SMTP, verify_peer=true (default)' => [
|
||||
'smtp://localhost:25',
|
||||
true,
|
||||
[],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,25 +277,13 @@ abstract class WebServicesBase
|
||||
$oLog->Set('userinfo', UserRights::GetUser());
|
||||
$oLog->Set('verb', $sVerb);
|
||||
$oLog->Set('result', $oRes->IsOk());
|
||||
$this->TrimAndSetValue($oLog, 'log_info', (string)$oRes->GetInfoAsText());
|
||||
$this->TrimAndSetValue($oLog, 'log_warning', (string)$oRes->GetWarningsAsText());
|
||||
$this->TrimAndSetValue($oLog, 'log_error', (string)$oRes->GetErrorsAsText());
|
||||
$this->TrimAndSetValue($oLog, 'data', (string)$oRes->GetReturnedDataAsText());
|
||||
$oLog->SetTrim('log_info', (string)$oRes->GetInfoAsText());
|
||||
$oLog->SetTrim('log_warning', (string)$oRes->GetWarningsAsText());
|
||||
$oLog->SetTrim('log_error', (string)$oRes->GetErrorsAsText());
|
||||
$oLog->SetTrim('data', (string)$oRes->GetReturnedDataAsText());
|
||||
$oLog->DBInsertNoReload();
|
||||
}
|
||||
|
||||
protected function TrimAndSetValue($oLog, $sAttCode, $sValue)
|
||||
{
|
||||
$oAttDef = MetaModel::GetAttributeDef(get_class($oLog), $sAttCode);
|
||||
if (is_object($oAttDef)) {
|
||||
$iMaxSize = $oAttDef->GetMaxSize();
|
||||
if ($iMaxSize && (mb_strlen($sValue) > $iMaxSize)) {
|
||||
$sValue = mb_substr($sValue, 0, $iMaxSize);
|
||||
}
|
||||
$oLog->Set($sAttCode, $sValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to set a scalar attribute
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user