Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
<?php
namespace Mautic\AssetBundle;
/**
* Events available for AssetBundle.
*/
final class AssetEvents
{
/**
* The mautic.asset_on_load event is dispatched when a public asset is downloaded, publicly viewed, or redirected to (remote).
*
* The event listener receives a
* Mautic\AssetBundle\Event\AssetLoadEvent instance.
*
* @var string
*/
public const ASSET_ON_LOAD = 'mautic.asset_on_load';
/**
* The mautic.asset_on_remote_browse event is dispatched when browsing a remote provider.
*
* The event listener receives a
* Mautic\AssetBundle\Event\RemoteAssetBrowseEvent instance.
*
* @var string
*/
public const ASSET_ON_REMOTE_BROWSE = 'mautic.asset_on_remote_browse';
/**
* The mautic.asset_on_upload event is dispatched before uploading a file.
*
* The event listener receives a
* Mautic\AssetBundle\Event\AssetEvent instance.
*
* @var string
*/
public const ASSET_ON_UPLOAD = 'mautic.asset_on_upload';
/**
* The mautic.asset_pre_save event is dispatched right before a asset is persisted.
*
* The event listener receives a
* Mautic\AssetBundle\Event\AssetEvent instance.
*
* @var string
*/
public const ASSET_PRE_SAVE = 'mautic.asset_pre_save';
/**
* The mautic.asset_post_save event is dispatched right after a asset is persisted.
*
* The event listener receives a
* Mautic\AssetBundle\Event\AssetEvent instance.
*
* @var string
*/
public const ASSET_POST_SAVE = 'mautic.asset_post_save';
/**
* The mautic.asset_pre_delete event is dispatched prior to when a asset is deleted.
*
* The event listener receives a
* Mautic\AssetBundle\Event\AssetEvent instance.
*
* @var string
*/
public const ASSET_PRE_DELETE = 'mautic.asset_pre_delete';
/**
* The mautic.asset_post_delete event is dispatched after a asset is deleted.
*
* The event listener receives a
* Mautic\AssetBundle\Event\AssetEvent instance.
*
* @var string
*/
public const ASSET_POST_DELETE = 'mautic.asset_post_delete';
/**
* The mautic.asset.on_campaign_trigger_decision event is fired when the campaign action triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_DECISION = 'mautic.asset.on_campaign_trigger_decision';
/**
* The mautic.asset.on_download_rate_winner event is fired when there is a need to determine download rate winner.
*
* The event listener receives a
* Mautic\CoreBundles\Event\DetermineWinnerEvent
*
* @var string
*/
public const ON_DETERMINE_DOWNLOAD_RATE_WINNER = 'mautic.asset.on_download_rate_winner';
}

View File

@@ -0,0 +1,128 @@
.thumbnail-preview img {
height: 100px;
}
.modal-body-content iframe, .preview-detail iframe {
height: 400px;
}
.form-group.preview img {
max-height: 260px;
}
@-webkit-keyframes passing-through { 0% { opacity: 0; -webkit-transform: translateY(40px); -moz-transform: translateY(40px); -ms-transform: translateY(40px); -o-transform: translateY(40px); transform: translateY(40px); }
30%, 70% { opacity: 1; -webkit-transform: translateY(0px); -moz-transform: translateY(0px); -ms-transform: translateY(0px); -o-transform: translateY(0px); transform: translateY(0px); }
100% { opacity: 0; -webkit-transform: translateY(-40px); -moz-transform: translateY(-40px); -ms-transform: translateY(-40px); -o-transform: translateY(-40px); transform: translateY(-40px); } }
@-moz-keyframes passing-through { 0% { opacity: 0; -webkit-transform: translateY(40px); -moz-transform: translateY(40px); -ms-transform: translateY(40px); -o-transform: translateY(40px); transform: translateY(40px); }
30%, 70% { opacity: 1; -webkit-transform: translateY(0px); -moz-transform: translateY(0px); -ms-transform: translateY(0px); -o-transform: translateY(0px); transform: translateY(0px); }
100% { opacity: 0; -webkit-transform: translateY(-40px); -moz-transform: translateY(-40px); -ms-transform: translateY(-40px); -o-transform: translateY(-40px); transform: translateY(-40px); } }
@keyframes passing-through { 0% { opacity: 0; -webkit-transform: translateY(40px); -moz-transform: translateY(40px); -ms-transform: translateY(40px); -o-transform: translateY(40px); transform: translateY(40px); }
30%, 70% { opacity: 1; -webkit-transform: translateY(0px); -moz-transform: translateY(0px); -ms-transform: translateY(0px); -o-transform: translateY(0px); transform: translateY(0px); }
100% { opacity: 0; -webkit-transform: translateY(-40px); -moz-transform: translateY(-40px); -ms-transform: translateY(-40px); -o-transform: translateY(-40px); transform: translateY(-40px); } }
@-webkit-keyframes slide-in { 0% { opacity: 0; -webkit-transform: translateY(40px); -moz-transform: translateY(40px); -ms-transform: translateY(40px); -o-transform: translateY(40px); transform: translateY(40px); }
30% { opacity: 1; -webkit-transform: translateY(0px); -moz-transform: translateY(0px); -ms-transform: translateY(0px); -o-transform: translateY(0px); transform: translateY(0px); } }
@-moz-keyframes slide-in { 0% { opacity: 0; -webkit-transform: translateY(40px); -moz-transform: translateY(40px); -ms-transform: translateY(40px); -o-transform: translateY(40px); transform: translateY(40px); }
30% { opacity: 1; -webkit-transform: translateY(0px); -moz-transform: translateY(0px); -ms-transform: translateY(0px); -o-transform: translateY(0px); transform: translateY(0px); } }
@keyframes slide-in { 0% { opacity: 0; -webkit-transform: translateY(40px); -moz-transform: translateY(40px); -ms-transform: translateY(40px); -o-transform: translateY(40px); transform: translateY(40px); }
30% { opacity: 1; -webkit-transform: translateY(0px); -moz-transform: translateY(0px); -ms-transform: translateY(0px); -o-transform: translateY(0px); transform: translateY(0px); } }
@-webkit-keyframes pulse { 0% { -webkit-transform: scale(1); -moz-transform: scale(1); -ms-transform: scale(1); -o-transform: scale(1); transform: scale(1); }
10% { -webkit-transform: scale(1.1); -moz-transform: scale(1.1); -ms-transform: scale(1.1); -o-transform: scale(1.1); transform: scale(1.1); }
20% { -webkit-transform: scale(1); -moz-transform: scale(1); -ms-transform: scale(1); -o-transform: scale(1); transform: scale(1); } }
@-moz-keyframes pulse { 0% { -webkit-transform: scale(1); -moz-transform: scale(1); -ms-transform: scale(1); -o-transform: scale(1); transform: scale(1); }
10% { -webkit-transform: scale(1.1); -moz-transform: scale(1.1); -ms-transform: scale(1.1); -o-transform: scale(1.1); transform: scale(1.1); }
20% { -webkit-transform: scale(1); -moz-transform: scale(1); -ms-transform: scale(1); -o-transform: scale(1); transform: scale(1); } }
@keyframes pulse { 0% { -webkit-transform: scale(1); -moz-transform: scale(1); -ms-transform: scale(1); -o-transform: scale(1); transform: scale(1); }
10% { -webkit-transform: scale(1.1); -moz-transform: scale(1.1); -ms-transform: scale(1.1); -o-transform: scale(1.1); transform: scale(1.1); }
20% { -webkit-transform: scale(1); -moz-transform: scale(1); -ms-transform: scale(1); -o-transform: scale(1); transform: scale(1); } }
.mdropzone, .mdropzone * { box-sizing: border-box; }
.mdropzone { min-height: 150px; border: none; background: var(--field); padding: 30px 30px; transition: var(--transition-all-productive); border-radius: var(--border-radius-md);}
.mdropzone:hover { background: var(--field-hover); }
.has-error .mdropzone {border: 2px solid #a94442;}
.is-success .mdropzone {border: 2px solid #00a08a;}
.mdropzone.dz-clickable { cursor: pointer; }
.mdropzone.dz-clickable * { cursor: default; }
.mdropzone.dz-clickable .dz-message, .mdropzone.dz-clickable .dz-message * { cursor: pointer; }
.mdropzone.dz-started .dz-message { display: none; }
.mdropzone.dz-drag-hover { border-style: solid; }
.mdropzone.dz-drag-hover .dz-message { opacity: 0.5; }
.mdropzone .dz-message { text-align: center; margin: 2em 0; }
.mdropzone .dz-preview { position: relative; display: inline-block; vertical-align: top; margin: 16px; min-height: 100px; }
.mdropzone .dz-preview:hover { z-index: 1000; }
.mdropzone .dz-preview:hover .dz-details { opacity: 1; }
.mdropzone .dz-preview.dz-file-preview .dz-image { border-radius: 20px; background: #999; background: linear-gradient(to bottom, #eee, #ddd); }
.mdropzone .dz-preview.dz-file-preview .dz-details { opacity: 1; }
.mdropzone .dz-preview.dz-image-preview { background: white; }
.mdropzone .dz-preview.dz-image-preview .dz-details { -webkit-transition: opacity 0.2s linear; -moz-transition: opacity 0.2s linear; -ms-transition: opacity 0.2s linear; -o-transition: opacity 0.2s linear; transition: opacity 0.2s linear; }
.mdropzone .dz-preview .dz-remove { font-size: 14px; text-align: center; display: block; cursor: pointer; border: none; }
.mdropzone .dz-preview .dz-remove:hover { text-decoration: underline; }
.mdropzone .dz-preview:hover .dz-details { opacity: 1; }
.mdropzone .dz-preview .dz-details { z-index: 20; position: absolute; top: 0; left: 0; opacity: 0; font-size: 13px; min-width: 100%; max-width: 100%; padding: 2em 1em; text-align: center; color: rgba(0, 0, 0, 0.9); line-height: 150%; }
.mdropzone .dz-preview .dz-details .dz-size { margin-bottom: 1em; font-size: 16px; }
.mdropzone .dz-preview .dz-details .dz-filename { white-space: nowrap; }
.mdropzone .dz-preview .dz-details .dz-filename:hover span { border: 1px solid rgba(200, 200, 200, 0.8); background-color: rgba(255, 255, 255, 0.8); }
.mdropzone .dz-preview .dz-details .dz-filename:not(:hover) { overflow: hidden; text-overflow: ellipsis; }
.mdropzone .dz-preview .dz-details .dz-filename:not(:hover) span { border: 1px solid transparent; }
.mdropzone .dz-preview .dz-details .dz-filename span, .mdropzone .dz-preview .dz-details .dz-size span { background-color: rgba(255, 255, 255, 0.4); padding: 0 0.4em; border-radius: 3px; }
.mdropzone .dz-preview:hover .dz-image img { -webkit-transform: scale(1.05, 1.05); -moz-transform: scale(1.05, 1.05); -ms-transform: scale(1.05, 1.05); -o-transform: scale(1.05, 1.05); transform: scale(1.05, 1.05); -webkit-filter: blur(8px); filter: blur(8px); }
.mdropzone .dz-preview .dz-image { border-radius: 20px; overflow: hidden; width: 120px; height: 120px; position: relative; display: block; z-index: 10; }
.mdropzone .dz-preview .dz-image img { display: block; }
.mdropzone .dz-preview.dz-success .dz-success-mark { -webkit-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); -moz-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); -ms-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); -o-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); }
.mdropzone .dz-preview.dz-error .dz-error-mark { opacity: 1; -webkit-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); -moz-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); -ms-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); -o-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); }
.mdropzone .dz-preview .dz-success-mark, .mdropzone .dz-preview .dz-error-mark { pointer-events: none; opacity: 0; z-index: 500; position: absolute; display: block; top: 50%; left: 50%; margin-left: -27px; margin-top: -27px; }
.mdropzone .dz-preview .dz-success-mark svg, .mdropzone .dz-preview .dz-error-mark svg { display: block; width: 54px; height: 54px; }
.mdropzone .dz-preview.dz-processing .dz-progress { opacity: 1; -webkit-transition: all 0.2s linear; -moz-transition: all 0.2s linear; -ms-transition: all 0.2s linear; -o-transition: all 0.2s linear; transition: all 0.2s linear; }
.mdropzone .dz-preview.dz-complete .dz-progress { opacity: 0; -webkit-transition: opacity 0.4s ease-in; -moz-transition: opacity 0.4s ease-in; -ms-transition: opacity 0.4s ease-in; -o-transition: opacity 0.4s ease-in; transition: opacity 0.4s ease-in; }
.mdropzone .dz-preview:not(.dz-processing) .dz-progress { -webkit-animation: pulse 6s ease infinite; -moz-animation: pulse 6s ease infinite; -ms-animation: pulse 6s ease infinite; -o-animation: pulse 6s ease infinite; animation: pulse 6s ease infinite; }
.mdropzone .dz-preview .dz-progress { opacity: 1; z-index: 1000; pointer-events: none; position: absolute; height: 16px; left: 50%; top: 50%; margin-top: -8px; width: 80px; margin-left: -40px; background: rgba(255, 255, 255, 0.9); -webkit-transform: scale(1); border-radius: 8px; overflow: hidden; }
.mdropzone .dz-preview .dz-progress .dz-upload { background: #333; background: linear-gradient(to bottom, #666, #444); position: absolute; top: 0; left: 0; bottom: 0; width: 0; -webkit-transition: width 300ms ease-in-out; -moz-transition: width 300ms ease-in-out; -ms-transition: width 300ms ease-in-out; -o-transition: width 300ms ease-in-out; transition: width 300ms ease-in-out; }
.mdropzone .dz-preview.dz-error .dz-error-message { display: block; }
.mdropzone .dz-preview.dz-error:hover .dz-error-message { opacity: 1; pointer-events: auto; }
.mdropzone .dz-preview .dz-error-message { pointer-events: none; z-index: 1000; position: absolute; display: block; display: none; opacity: 0; -webkit-transition: opacity 0.3s ease; -moz-transition: opacity 0.3s ease; -ms-transition: opacity 0.3s ease; -o-transition: opacity 0.3s ease; transition: opacity 0.3s ease; border-radius: 8px; font-size: 13px; top: 130px; left: -10px; width: 140px; background: #be2626; background: linear-gradient(to bottom, #be2626, #a92222); padding: 0.5em 1.2em; color: white; }
.mdropzone .dz-preview .dz-error-message:after { content: ''; position: absolute; top: -6px; left: 64px; width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid #a94442; }
/*
* The MIT License
* Copyright (c) 2012 Matias Meno <m@tias.me>
*/
.mdropzone, .mdropzone * {
box-sizing: border-box; }
.mdropzone {
position: relative;
}
.mdropzone .dz-preview {
position: relative;
display: inline-block;
width: 120px;
margin: 0.5em;
}
.mdropzone .dz-preview .dz-progress {
display: block;
height: 15px;
border: 1px solid #aaa;
}
.mdropzone .dz-preview .dz-progress .dz-upload {
display: block;
height: 100%;
width: 0;
background: green;
}
.mdropzone .dz-preview .dz-error-message {
color: #fff;
display: none;
}
.mdropzone .dz-preview.dz-error .dz-error-message, .mdropzone .dz-preview.dz-error .dz-error-mark {
display: block;
}
.mdropzone .dz-preview.dz-success .dz-success-mark {
display: block;
}
.mdropzone .dz-preview .dz-error-mark, .mdropzone .dz-preview .dz-success-mark {
position: absolute;
display: none;
left: 30px;
top: 30px;
width: 54px;
height: 58px;
left: 50%;
margin-left: -27px;
}

View File

@@ -0,0 +1,184 @@
//AssetBundle
Mautic.assetOnLoad = function (container) {
if (typeof mauticAssetUploadEndpoint !== 'undefined' && typeof Mautic.assetDropzone == 'undefined' && mQuery('div#dropzone').length) {
Mautic.initializeDropzone();
}
};
Mautic.assetOnUnload = function(id) {
if (id === '#app-content') {
delete Mautic.assetDropzone;
}
};
Mautic.updateRemoteBrowser = function(provider, path) {
path = typeof path !== 'undefined' ? path : '';
var spinner = mQuery('<i class="ri-loader-3-line ri-spin ri-fw"></i>');
spinner.appendTo('#tab' + provider + ' a');
mQuery.ajax({
url: mauticAjaxUrl,
type: "POST",
data: "action=asset:fetchRemoteFiles&provider=" + provider + "&path=" + path,
dataType: "json",
success: function (response) {
if (response.success) {
mQuery('div#remoteFileBrowser').html(response.output);
mQuery('.remote-file-search').quicksearch('#remoteFileBrowser .remote-file-list a');
} else {
const flashMessage = Mautic.addErrorFlashMessage(response.message);
Mautic.setFlashes(flashMessage);
}
},
error: function (request, textStatus, errorThrown) {
Mautic.processAjaxError(request, textStatus, errorThrown);
},
complete: function() {
spinner.remove();
}
})
};
Mautic.selectRemoteFile = function(url) {
mQuery('#asset_remotePath').val(url);
mQuery('#RemoteFileModal').modal('hide');
};
Mautic.changeAssetStorageLocation = function() {
if (mQuery('#asset_storageLocation_0').prop('checked')) {
mQuery('#storage-local').removeClass('hide');
mQuery('#storage-remote').addClass('hide');
mQuery('#remote-button').addClass('hide');
} else {
mQuery('#storage-local').addClass('hide');
mQuery('#storage-remote').removeClass('hide');
mQuery('#remote-button').removeClass('hide');
}
};
Mautic.initializeDropzone = function() {
var options = {
url: mauticAssetUploadEndpoint,
uploadMultiple: false,
filesizeBase: 1024,
init: function() {
this.on("addedfile", function() {
if (this.files[1] != null) {
this.removeFile(this.files[0]);
}
});
}
};
if (typeof mauticAssetUploadMaxSize !== 'undefined') {
options.maxFilesize = mauticAssetUploadMaxSize;
}
if (typeof mauticAssetUploadMaxSizeError !== 'undefined') {
options.dictFileTooBig = mauticAssetUploadMaxSizeError;
}
if (typeof mauticAssetUploadExtensions !== 'undefined') {
options.acceptedFiles = mauticAssetUploadExtensions;
}
if (typeof mauticAssetUploadExtensionError !== 'undefined') {
options.dictInvalidFileType = mauticAssetUploadExtensionError;
}
Mautic.assetDropzone = new Dropzone("div#dropzone", options);
var preview = mQuery('.preview div.text-center');
Mautic.assetDropzone.on("sending", function (file, request, formData) {
request.setRequestHeader('X-CSRF-Token', mauticAjaxCsrf);
formData.append('tempId', mQuery('#asset_tempId').val());
}).on("addedfile", function (file) {
preview.fadeOut('fast');
}).on("success", function (file, response, progress) {
if (response.tmpFileName) {
mQuery('#asset_tempName').val(response.tmpFileName);
}
var messageArea = mQuery('.mdropzone-error');
if (response.error || !response.tmpFileName) {
if (!response.error) {
var errorText = '';
} else {
var errorText = (typeof response.error == 'object') ? response.error.text : response.error;
}
messageArea.text(errorText);
messageArea.closest('.form-group').addClass('has-error').removeClass('is-success');
// invoke the error
var node, _i, _len, _ref, _results;
file.previewElement.classList.add('dz-error');
_ref = file.previewElement.querySelectorAll('data-dz-errormessage');
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
node = _ref[_i];
_results.push(node.textContent = errorText);
}
return _results;
} else {
messageArea.text('');
messageArea.closest('.form-group').removeClass('has-error').addClass('is-success');
}
var titleInput = mQuery('#asset_title');
if (file.name && !titleInput.val()) {
titleInput.val(file.name);
}
if (file.name) {
mQuery('#asset_originalFileName').val(file.name);
}
}).on("error", function (file, response) {
preview.fadeIn('fast');
var messageArea = mQuery('.mdropzone-error');
// Dropzone error is just a text in the response var
if (typeof response == "string") {
response = {'error': response};
}
if (response.error) {
if (!response.error) {
var errorText = '';
} else {
var errorText = (typeof response.error == 'object') ? response.error.text : response.error;
}
messageArea.text(errorText);
messageArea.closest('.form-group').addClass('has-error').removeClass('is-success');
// invoke the error
var node, _i, _len, _ref, _results;
file.previewElement.classList.add('dz-error');
_ref = file.previewElement.querySelectorAll('[data-dz-errormessage]');
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
node = _ref[_i];
_results.push(node.textContent = errorText);
}
return _results;
}
}).on("thumbnail", function (file, url) {
if (file.accepted === true) {
var extension = file.name.substr((file.name.lastIndexOf('.') +1)).toLowerCase();
var previewContent = '';
if (mQuery.inArray(extension, ['jpg', 'jpeg', 'gif', 'png']) !== -1) {
previewContent = mQuery('<img />').addClass('img-thumbnail').attr('src', url);
} else if (extension === 'pdf') {
previewContent = mQuery('<iframe />').attr('src', url);
}
preview.empty().html(previewContent);
preview.fadeIn('fast');
}
});
}

View File

@@ -0,0 +1,114 @@
<?php
return [
'routes' => [
'main' => [
'mautic_asset_index' => [
'path' => '/assets/{page}',
'controller' => 'Mautic\AssetBundle\Controller\AssetController::indexAction',
],
'mautic_asset_remote' => [
'path' => '/assets/remote',
'controller' => 'Mautic\AssetBundle\Controller\AssetController::remoteAction',
],
'mautic_asset_action' => [
'path' => '/assets/{objectAction}/{objectId}',
'controller' => 'Mautic\AssetBundle\Controller\AssetController::executeAction',
],
],
'api' => [
'mautic_api_assetsstandard' => [
'standard_entity' => true,
'name' => 'assets',
'path' => '/assets',
'controller' => Mautic\AssetBundle\Controller\Api\AssetApiController::class,
],
],
'public' => [
'mautic_asset_download' => [
'path' => '/asset/{slug}',
'controller' => 'Mautic\AssetBundle\Controller\PublicController::downloadAction',
'defaults' => [
'slug' => '',
],
],
],
],
'menu' => [
'main' => [
'items' => [
'mautic.asset.assets' => [
'route' => 'mautic_asset_index',
'access' => ['asset:assets:viewown', 'asset:assets:viewother'],
'parent' => 'mautic.core.components',
'priority' => 300,
],
],
],
],
'categories' => [
'asset' => [
'class' => Mautic\AssetBundle\Entity\Asset::class,
],
],
'services' => [
'permissions' => [
'mautic.asset.permissions' => [
'class' => Mautic\AssetBundle\Security\Permissions\AssetPermissions::class,
'arguments' => [
'mautic.helper.core_parameters',
],
],
],
'others' => [
'mautic.asset.upload.error.handler' => [
'class' => Mautic\AssetBundle\ErrorHandler\DropzoneErrorHandler::class,
],
// Override the DropzoneController
'oneup_uploader.controller.dropzone.class' => [
'class' => Mautic\AssetBundle\Controller\UploadController::class,
],
],
'fixtures' => [
'mautic.asset.fixture.asset' => [
'class' => Mautic\AssetBundle\DataFixtures\ORM\LoadAssetData::class,
'tag' => Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG,
],
],
],
'parameters' => [
'upload_dir' => '%mautic.application_dir%/media/files',
'max_size' => '6',
'allowed_extensions' => ['csv', 'doc', 'docx', 'epub', 'gif', 'jpg', 'jpeg', 'mpg', 'mpeg', 'mp3', 'odt', 'odp', 'ods', 'pdf', 'png', 'ppt', 'pptx', 'tif', 'tiff', 'txt', 'xls', 'xlsx', 'wav'],
'streamed_extensions' => ['gif', 'jpg', 'jpeg', 'mpg', 'mpeg', 'mp3', 'pdf', 'png', 'wav'],
'allowed_mimetypes' => [
'csv' => 'text/csv',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'epub' => 'application/epub+zip',
'gif' => 'image/gif',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'mpg' => 'video/mpeg',
'mpeg' => 'video/mpeg',
'mp3' => 'audio/mpeg',
'odt' => 'application/vnd.oasis.opendocument.text',
'odp' => 'application/vnd.oasis.opendocument.presentation',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
'pdf' => 'application/pdf',
'png' => 'image/png',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'txt' => 'text/plain',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'wav' => 'audio/wav',
],
],
];

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
'Controller/UploadController.php',
];
$services->load('Mautic\\AssetBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\AssetBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias('mautic.asset.helper.token', Mautic\AssetBundle\Helper\TokenHelper::class);
$services->alias('mautic.asset.model.asset', Mautic\AssetBundle\Model\AssetModel::class);
$services->alias(Oneup\UploaderBundle\Templating\Helper\UploaderHelper::class, 'oneup_uploader.templating.uploader_helper');
$services->alias('mautic.asset.repository.download', Mautic\AssetBundle\Entity\DownloadRepository::class);
};

View File

@@ -0,0 +1,72 @@
<?php
namespace Mautic\AssetBundle\Controller;
use Gaufrette\Filesystem;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Event\RemoteAssetBrowseEvent;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Symfony\Component\HttpFoundation\Request;
class AjaxController extends CommonAjaxController
{
public function categoryListAction(Request $request): \Symfony\Component\HttpFoundation\JsonResponse
{
$assetModel = $this->getModel('asset');
\assert($assetModel instanceof AssetModel);
$filter = InputHelper::clean($request->query->get('filter'));
$results = $assetModel->getLookupResults('category', $filter, 10);
$dataArray = [];
foreach ($results as $r) {
$dataArray[] = [
'label' => $r['title']." ({$r['id']})",
'value' => $r['id'],
];
}
return $this->sendJsonResponse($dataArray);
}
/**
* @throws \Exception
*/
public function fetchRemoteFilesAction(Request $request, IntegrationHelper $integrationHelper): \Symfony\Component\HttpFoundation\JsonResponse
{
$provider = InputHelper::string($request->request->get('provider'));
$path = InputHelper::string($request->request->get('path', ''));
$dispatcher = $this->dispatcher;
$name = AssetEvents::ASSET_ON_REMOTE_BROWSE;
if (!$dispatcher->hasListeners($name)) {
return $this->sendJsonResponse(['success' => 0]);
}
/** @var \Mautic\PluginBundle\Integration\AbstractIntegration $integration */
$integration = $integrationHelper->getIntegrationObject($provider);
$event = new RemoteAssetBrowseEvent($integration);
$dispatcher->dispatch($event, $name);
if (!$adapter = $event->getAdapter()) {
return $this->sendJsonResponse([
'success' => 0,
'message' => $event->getFailureMessage() ?? null,
]);
}
$connector = new Filesystem($adapter);
$output = $this->renderView(
'@MauticAsset/Remote/list.html.twig',
[
'connector' => $connector,
'integration' => $integration,
'items' => $connector->listKeys($path),
]
);
return $this->sendJsonResponse(['success' => 1, 'output' => $output]);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Mautic\AssetBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Asset>
*/
class AssetApiController extends CommonApiController
{
/**
* @var AssetModel|null
*/
protected $model;
public function __construct(
CorePermissions $security,
Translator $translator,
EntityResultHelper $entityResultHelper,
RouterInterface $router,
FormFactoryInterface $formFactory,
AppVersion $appVersion,
RequestStack $requestStack,
private CoreParametersHelper $parametersHelper,
ManagerRegistry $doctrine,
ModelFactory $modelFactory,
EventDispatcherInterface $dispatcher,
CoreParametersHelper $coreParametersHelper,
) {
$assetModel = $modelFactory->getModel('asset');
\assert($assetModel instanceof AssetModel);
$this->model = $assetModel;
$this->entityClass = Asset::class;
$this->entityNameOne = 'asset';
$this->entityNameMulti = 'assets';
$this->serializerGroups = ['assetDetails', 'categoryList', 'publishDetails'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Gives child controllers opportunity to analyze and do whatever to an entity before going through serializer.
*/
protected function preSerializeEntity(object $entity, string $action = 'view'): void
{
$entity->setDownloadUrl(
$this->model->generateUrl($entity, true)
);
}
/**
* Convert posted parameters into what the form needs in order to successfully bind.
*
* @return mixed
*/
protected function prepareParametersForBinding(Request $request, $parameters, $entity, $action)
{
$assetDir = $this->parametersHelper->get('upload_dir');
$entity->setUploadDir($assetDir);
if (isset($parameters['file'])) {
if ('local' === $parameters['storageLocation']) {
$entity->setPath($parameters['file']);
$entity->setFileInfoFromFile();
if (null === $entity->loadFile()) {
return $this->returnError('File '.$parameters['file'].' was not found in the asset directory.', Response::HTTP_BAD_REQUEST);
}
} elseif ('remote' === $parameters['storageLocation']) {
$parameters['remotePath'] = $parameters['file'];
$entity->setTitle($parameters['title']);
$entity->setStorageLocation('remote');
$entity->setRemotePath($parameters['remotePath']);
$entity->preUpload();
$entity->upload();
}
unset($parameters['file']);
} elseif ('new' === $action) {
return $this->returnError('File of the asset is required.', Response::HTTP_BAD_REQUEST);
}
return $parameters;
}
}

View File

@@ -0,0 +1,754 @@
<?php
namespace Mautic\AssetBundle\Controller;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Form\Type\DateRangeType;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\FileHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use Oneup\UploaderBundle\Templating\Helper\UploaderHelper;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class AssetController extends FormController
{
/**
* @return JsonResponse|Response
*/
public function indexAction(Request $request, CoreParametersHelper $parametersHelper, AssetModel $assetModel, int $page = 1)
{
// set some permissions
$permissions = $this->security->isGranted([
'asset:assets:viewown',
'asset:assets:viewother',
'asset:assets:create',
'asset:assets:editown',
'asset:assets:editother',
'asset:assets:deleteown',
'asset:assets:deleteother',
'asset:assets:publishown',
'asset:assets:publishother',
], 'RETURN_ARRAY');
if (!$permissions['asset:assets:viewown'] && !$permissions['asset:assets:viewother']) {
return $this->accessDenied();
}
$this->setListFilters();
$limit = $request->getSession()->get('mautic.asset.limit', $parametersHelper->get('default_assetlimit'));
$start = (1 === $page) ? 0 : (($page - 1) * $limit);
if ($start < 0) {
$start = 0;
}
$search = $request->get('search', $request->getSession()->get('mautic.asset.filter', ''));
$request->getSession()->set('mautic.asset.filter', $search);
$filter = ['string' => $search, 'force' => []];
if (!$permissions['asset:assets:viewother']) {
$filter['force'][] =
['column' => 'a.createdBy', 'expr' => 'eq', 'value' => $this->user->getId()];
}
$orderBy = $request->getSession()->get('mautic.asset.orderby', 'a.dateModified');
$orderByDir = $request->getSession()->get('mautic.asset.orderbydir', $this->getDefaultOrderDirection());
$assets = $assetModel->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]
);
$count = count($assets);
if ($count && $count < ($start + 1)) {
// the number of entities are now less then the current asset so redirect to the last asset
if (1 === $count) {
$lastPage = 1;
} else {
$lastPage = (ceil($count / $limit)) ?: 1;
}
$request->getSession()->set('mautic.asset.asset', $lastPage);
$returnUrl = $this->generateUrl('mautic_asset_index', ['page' => $lastPage]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['asset' => $lastPage],
'contentTemplate' => 'Mautic\AssetBundle\Controller\AssetController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
],
]);
}
// set what asset currently on so that we can return here after form submission/cancellation
$request->getSession()->set('mautic.asset.page', $page);
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
// retrieve a list of categories
$categories = $assetModel->getLookupResults('category', '', 0);
return $this->delegateView([
'viewParameters' => [
'searchValue' => $search,
'items' => $assets,
'categories' => $categories,
'limit' => $limit,
'permissions' => $permissions,
'model' => $assetModel,
'tmpl' => $tmpl,
'page' => $page,
'security' => $this->security,
],
'contentTemplate' => '@MauticAsset/Asset/list.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
'route' => $this->generateUrl('mautic_asset_index', ['page' => $page]),
],
]);
}
/**
* Loads a specific form into the detailed panel.
*
* @param int $objectId
*
* @return JsonResponse|Response
*/
public function viewAction(Request $request, AssetModel $model, $objectId)
{
$activeAsset = $model->getEntity($objectId);
// set the asset we came from
$page = $request->getSession()->get('mautic.asset.page', 1);
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'details') : 'details';
// Init the date range filter form
$dateRangeValues = $request->get('daterange', []);
$action = $this->generateUrl('mautic_asset_action', ['objectAction' => 'view', 'objectId' => $objectId]);
$dateRangeForm = $this->formFactory->create(DateRangeType::class, $dateRangeValues, ['action' => $action]);
if (null === $activeAsset) {
// set the return URL
$returnUrl = $this->generateUrl('mautic_asset_index', ['page' => $page]);
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\AssetBundle\Controller\AssetController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
],
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.asset.asset.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]);
} elseif (!$this->security->hasEntityAccess('asset:assets:viewown', 'asset:assets:viewother', $activeAsset->getCreatedBy())) {
return $this->accessDenied();
}
// Audit Log
$auditLogModel = $this->getModel('core.auditlog');
\assert($auditLogModel instanceof AuditLogModel);
$logs = $auditLogModel->getLogForObject('asset', $activeAsset->getId(), $activeAsset->getDateAdded());
return $this->delegateView([
'returnUrl' => $action,
'viewParameters' => [
'activeAsset' => $activeAsset,
'tmpl' => $tmpl,
'permissions' => $this->security->isGranted([
'asset:assets:viewown',
'asset:assets:viewother',
'asset:assets:create',
'asset:assets:editown',
'asset:assets:editother',
'asset:assets:deleteown',
'asset:assets:deleteother',
'asset:assets:publishown',
'asset:assets:publishother',
], 'RETURN_ARRAY'),
'stats' => [
'downloads' => [
'total' => $activeAsset->getDownloadCount(),
'unique' => $activeAsset->getUniqueDownloadCount(),
'timeStats' => $model->getDownloadsLineChartData(
null,
new \DateTime($dateRangeForm->get('date_from')->getData()),
new \DateTime($dateRangeForm->get('date_to')->getData()),
null,
['asset_id' => $activeAsset->getId()]
),
],
],
'security' => $this->security,
'assetDownloadUrl' => $model->generateUrl($activeAsset, true),
'logs' => $logs,
'dateRangeForm' => $dateRangeForm->createView(),
],
'contentTemplate' => '@MauticAsset/Asset/'.$tmpl.'.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
],
]);
}
/**
* Show a preview of the file.
*
* @param int $objectId
*
* @return JsonResponse|Response
*/
public function previewAction(Request $request, AssetModel $model, $objectId)
{
$activeAsset = $model->getEntity($objectId);
if (null === $activeAsset || !$this->security->hasEntityAccess('asset:assets:viewown', 'asset:assets:viewother', $activeAsset->getCreatedBy())) {
return $this->modalAccessDenied();
}
$download = $request->query->get('download', 0);
// Display the file directly in the browser just for selected extensions
$defaultStream = in_array($activeAsset->getExtension(), $this->coreParametersHelper->get('streamed_extensions')) ? '1' : null;
$stream = $request->query->get('stream', $defaultStream);
if ('1' === $download || '1' === $stream) {
try {
// set the uploadDir
$activeAsset->setUploadDir($this->coreParametersHelper->get('upload_dir'));
$contents = $activeAsset->getFileContents();
} catch (\Exception) {
return $this->notFound();
}
$response = new Response();
$response->headers->set('Content-Type', $activeAsset->getFileMimeType());
if ('1' === $download) {
$response->headers->set('Content-Disposition', 'attachment;filename="'.$activeAsset->getOriginalFileName());
}
$response->setContent($contents);
return $response;
}
return $this->delegateView([
'viewParameters' => [
'activeAsset' => $activeAsset,
'assetDownloadUrl' => $model->generateUrl($activeAsset),
],
'contentTemplate' => '@MauticAsset/Modules/preview.html.twig',
'passthroughVars' => [
'route' => false,
],
]);
}
/**
* Generates new form and processes post data.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function newAction(Request $request, CoreParametersHelper $parametersHelper, UploaderHelper $uploaderHelper, IntegrationHelper $integrationHelper, AssetModel $model, $entity = null)
{
if (null == $entity) {
$entity = $model->getEntity();
}
$entity->setMaxSize(FileHelper::convertMegabytesToBytes($this->coreParametersHelper->get('max_size')));
$method = $request->getMethod();
$session = $request->getSession();
if (!$this->security->isGranted('asset:assets:create')) {
return $this->accessDenied();
}
$maxSize = $model->getMaxUploadSize();
$extensions = '.'.implode(', .', $this->coreParametersHelper->get('allowed_extensions'));
$maxSizeError = $this->translator->trans('mautic.asset.asset.error.file.size', [
'%fileSize%' => '{{filesize}}',
'%maxSize%' => '{{maxFilesize}}',
], 'validators');
$extensionError = $this->translator->trans('mautic.asset.asset.error.file.extension.js', [
'%extensions%' => $extensions,
], 'validators');
// Create temporary asset ID
$asset = $request->request->all()['asset'] ?? [];
$tempId = 'POST' === $method ? ($asset['tempId'] ?? '') : uniqid('tmp_');
$entity->setTempId($tempId);
// Set the page we came from
$page = $session->get('mautic.asset.page', 1);
$action = $this->generateUrl('mautic_asset_action', ['objectAction' => 'new']);
$uploadEndpoint = $uploaderHelper->endpoint('asset');
// create the form
$form = $model->createForm($entity, $this->formFactory, $action);
// /Check for a submitted form and process it
if ('POST' == $method) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$entity->setUploadDir($parametersHelper->get('upload_dir'));
$entity->preUpload();
$entity->upload();
$entity->setDateModified(new \DateTime());
// form is valid so process the data
$model->saveEntity($entity);
// remove the asset from request
$request->files->remove('asset');
$this->addFlashMessage('mautic.core.notice.created', [
'%name%' => $entity->getTitle(),
'%menu_link%' => 'mautic_asset_index',
'%url%' => $this->generateUrl('mautic_asset_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
if (!$this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
// return edit view so that all the session stuff is loaded
return $this->editAction($request, $uploaderHelper, $integrationHelper, $model, $entity->getId(), true);
}
$viewParameters = [
'objectAction' => 'view',
'objectId' => $entity->getId(),
];
$returnUrl = $this->generateUrl('mautic_asset_action', $viewParameters);
$template = 'Mautic\AssetBundle\Controller\AssetController::viewAction';
}
} else {
$viewParameters = ['page' => $page];
$returnUrl = $this->generateUrl('mautic_asset_index', $viewParameters);
$template = 'Mautic\AssetBundle\Controller\AssetController::indexAction';
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect([
'returnUrl' => $returnUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $template,
'passthroughVars' => [
'activeLink' => 'mautic_asset_index',
'mauticContent' => 'asset',
],
]);
}
}
// Check for integrations to cloud providers
$integrations = $integrationHelper->getIntegrationObjects(null, ['cloud_storage']);
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'activeAsset' => $entity,
'assetDownloadUrl' => $model->generateUrl($entity),
'integrations' => $integrations,
'startOnLocal' => $entity->isLocal(),
'uploadEndpoint' => $uploadEndpoint,
'maxSize' => $maxSize,
'maxSizeError' => $maxSizeError,
'extensions' => $extensions,
'extensionError' => $extensionError,
],
'contentTemplate' => '@MauticAsset/Asset/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
'route' => $this->generateUrl('mautic_asset_action', [
'objectAction' => 'new',
]),
],
]);
}
/**
* Generates edit form and processes post data.
*
* @param int $objectId
* @param bool $ignorePost
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function editAction(Request $request, UploaderHelper $uploaderHelper, IntegrationHelper $integrationHelper, AssetModel $model, $objectId, $ignorePost = false)
{
$entity = $model->getEntity($objectId);
if (!$this->security->hasEntityAccess('asset:assets:editown', 'asset:assets:editother', $entity->getCreatedBy())) {
return $this->accessDenied();
}
$entity->setMaxSize(FileHelper::convertMegabytesToBytes($this->coreParametersHelper->get('max_size')));
$session = $request->getSession();
$page = $session->get('mautic.asset.page', 1);
$method = $request->getMethod();
$maxSize = $model->getMaxUploadSize();
$extensions = '.'.implode(', .', $this->coreParametersHelper->get('allowed_extensions'));
$maxSizeError = $this->translator->trans('mautic.asset.asset.error.file.size', [
'%fileSize%' => '{{filesize}}',
'%maxSize%' => '{{maxFilesize}}',
], 'validators');
$extensionError = $this->translator->trans('mautic.asset.asset.error.file.extension.js', [
'%extensions%' => $extensions,
], 'validators');
// set the return URL
$returnUrl = $this->generateUrl('mautic_asset_index', ['page' => $page]);
$uploadEndpoint = $uploaderHelper->endpoint('asset');
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\AssetBundle\Controller\AssetController::indexAction',
'passthroughVars' => [
'activeLink' => 'mautic_asset_index',
'mauticContent' => 'asset',
],
];
// not found
if (null === $entity) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.asset.asset.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
])
);
} elseif (!$this->security->hasEntityAccess(
'asset:assets:viewown', 'asset:assets:viewother', $entity->getCreatedBy()
)
) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $entity, 'asset.asset');
}
// Create temporary asset ID
$asset = $request->request->all()['asset'] ?? [];
$tempId = 'POST' === $method ? ($asset['tempId'] ?? '') : uniqid('tmp_');
$entity->setTempId($tempId);
// Create the form
$action = $this->generateUrl('mautic_asset_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$form = $model->createForm($entity, $this->formFactory, $action);
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' == $method) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$entity->setUploadDir($this->coreParametersHelper->get('upload_dir'));
$entity->preUpload();
$entity->upload();
// form is valid so process the data
$model->saveEntity($entity, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
// remove the asset from request
$request->files->remove('asset');
$this->addFlashMessage('mautic.core.notice.updated', [
'%name%' => $entity->getTitle(),
'%menu_link%' => 'mautic_asset_index',
'%url%' => $this->generateUrl('mautic_asset_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
]);
$returnUrl = $this->generateUrl('mautic_asset_action', [
'objectAction' => 'view',
'objectId' => $entity->getId(),
]);
$viewParams = ['objectId' => $entity->getId()];
$template = 'Mautic\AssetBundle\Controller\AssetController::viewAction';
}
} else {
// clear any modified content
$session->remove('mautic.asestbuilder.'.$objectId.'.content');
// unlock the entity
$model->unlockEntity($entity);
$returnUrl = $this->generateUrl('mautic_asset_index', ['page' => $page]);
$viewParams = ['page' => $page];
$template = 'Mautic\AssetBundle\Controller\AssetController::indexAction';
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect(
array_merge($postActionVars, [
'returnUrl' => $returnUrl,
'viewParameters' => $viewParams,
'contentTemplate' => $template,
])
);
}
} else {
// lock the entity
$model->lockEntity($entity);
}
// Check for integrations to cloud providers
$integrations = $integrationHelper->getIntegrationObjects(null, ['cloud_storage']);
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
'activeAsset' => $entity,
'assetDownloadUrl' => $model->generateUrl($entity),
'integrations' => $integrations,
'startOnLocal' => $entity->isLocal(),
'uploadEndpoint' => $uploadEndpoint,
'maxSize' => $maxSize,
'maxSizeError' => $maxSizeError,
'extensions' => $extensions,
'extensionError' => $extensionError,
],
'contentTemplate' => '@MauticAsset/Asset/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
'route' => $this->generateUrl('mautic_asset_action', [
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]),
],
]);
}
/**
* Clone an entity.
*
* @param int $objectId
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function cloneAction(Request $request, CoreParametersHelper $parametersHelper, UploaderHelper $uploaderHelper, IntegrationHelper $integrationHelper, AssetModel $model, $objectId)
{
$entity = $model->getEntity($objectId);
$clone = null;
if (null != $entity) {
if (!$this->security->isGranted('asset:assets:create')
|| !$this->security->hasEntityAccess(
'asset:assets:viewown', 'asset:assets:viewother', $entity->getCreatedBy()
)
) {
return $this->accessDenied();
}
$clone = clone $entity;
$clone->setDownloadCount(0);
$clone->setUniqueDownloadCount(0);
$clone->setRevision(0);
$clone->setIsPublished(false);
}
return $this->newAction($request, $parametersHelper, $uploaderHelper, $integrationHelper, $model, $clone);
}
/**
* Deletes the entity.
*
* @param int $objectId
*
* @return Response
*/
public function deleteAction(Request $request, AssetModel $model, $objectId)
{
$page = $request->getSession()->get('mautic.asset.page', 1);
$returnUrl = $this->generateUrl('mautic_asset_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\AssetBundle\Controller\AssetController::indexAction',
'passthroughVars' => [
'activeLink' => 'mautic_asset_index',
'mauticContent' => 'asset',
],
];
if ('POST' === $request->getMethod()) {
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.asset.asset.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->hasEntityAccess(
'asset:assets:deleteown',
'asset:assets:deleteother',
$entity->getCreatedBy()
)
) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
return $this->isLocked($postActionVars, $entity, 'asset.asset');
}
$entity->removeUpload();
$model->deleteEntity($entity);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $entity->getTitle(),
'%id%' => $objectId,
],
];
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Deletes a group of entities.
*/
public function batchDeleteAction(Request $request, AssetModel $model): Response
{
$page = $request->getSession()->get('mautic.asset.page', 1);
$returnUrl = $this->generateUrl('mautic_asset_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\AssetBundle\Controller\AssetController::indexAction',
'passthroughVars' => [
'activeLink' => 'mautic_asset_index',
'mauticContent' => 'asset',
],
];
if ('POST' === $request->getMethod()) {
$ids = json_decode($request->query->get('ids', '{}'));
$deleteIds = [];
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.asset.asset.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->hasEntityAccess(
'asset:assets:deleteown', 'asset:assets:deleteother', $entity->getCreatedBy()
)
) {
$flashes[] = $this->accessDenied(true);
} elseif ($model->isLocked($entity)) {
$flashes[] = $this->isLocked($postActionVars, $entity, 'asset', true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
$entities = $model->deleteEntities($deleteIds);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.asset.asset.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
} // else don't do anything
return $this->postActionRedirect(
array_merge($postActionVars, [
'flashes' => $flashes,
])
);
}
/**
* Renders the container for the remote file browser.
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
*/
public function remoteAction(Request $request, IntegrationHelper $integrationHelper): Response
{
// Check for integrations to cloud providers
$integrations = $integrationHelper->getIntegrationObjects(null, ['cloud_storage']);
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
return $this->delegateView([
'viewParameters' => [
'integrations' => $integrations,
'tmpl' => $tmpl,
],
'contentTemplate' => '@MauticAsset/Remote/browse.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_asset_index',
'mauticContent' => 'asset',
'route' => $this->generateUrl('mautic_asset_index', ['page' => $request->getSession()->get('mautic.asset.page', 1)]),
],
]);
}
public function getModelName(): string
{
return 'asset';
}
protected function getDefaultOrderDirection(): string
{
return 'DESC';
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Mautic\AssetBundle\Controller;
use Mautic\CoreBundle\Controller\FormController as CommonFormController;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PublicController extends CommonFormController
{
/**
* @param string $slug
*
* @return Response
*/
public function downloadAction(Request $request, CoreParametersHelper $parametersHelper, $slug)
{
// find the asset
/** @var \Mautic\AssetBundle\Model\AssetModel $model */
$model = $this->getModel('asset');
/** @var \Mautic\AssetBundle\Entity\Asset $entity */
$entity = $model->getEntityBySlugs($slug);
if (!empty($entity)) {
$published = $entity->isPublished();
// make sure the asset is published or deny access if not
if ((!$published) && (!$this->security->hasEntityAccess('asset:assets:viewown', 'asset:assets:viewother', $entity->getCreatedBy()))) {
$model->trackDownload($entity, $request, 401);
return $this->accessDenied();
}
// make sure URLs match up
$url = $model->generateUrl($entity, false);
$requestUri = $request->getRequestUri();
// remove query
$query = $request->getQueryString();
if (!empty($query)) {
$requestUri = str_replace("?{$query}", '', $url);
}
// redirect if they don't match
if ($requestUri != $url) {
$model->trackDownload($entity, $request, 301);
return $this->redirect($url, 301);
}
if ($entity->isRemote()) {
$model->trackDownload($entity, $request, 200);
// Redirect to remote URL
$response = new RedirectResponse($entity->getRemotePath());
} else {
try {
// set the uploadDir
$entity->setUploadDir($parametersHelper->get('upload_dir'));
$contents = $entity->getFileContents();
$model->trackDownload($entity, $request, 200);
} catch (\Exception) {
$model->trackDownload($entity, $request, 404);
return $this->notFound();
}
$response = new Response();
if ($entity->getDisallow()) {
$response->headers->set('X-Robots-Tag', 'noindex, nofollow, noarchive');
}
$response->headers->set('Content-Type', $entity->getFileMimeType());
// Display the file directly in the browser just for selected extensions
$stream = $request->get('stream', in_array($entity->getExtension(), $this->coreParametersHelper->get('streamed_extensions')));
if (!$stream) {
$response->headers->set('Content-Disposition', 'attachment;filename="'.$entity->getOriginalFileName());
}
$response->setContent($contents);
}
return $response;
}
return $this->notFound();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Mautic\AssetBundle\Controller;
use Oneup\UploaderBundle\Controller\DropzoneController;
use Oneup\UploaderBundle\Uploader\Response\EmptyResponse;
use Symfony\Component\HttpFoundation\File\Exception\UploadException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Contracts\Translation\TranslatorInterface;
class UploadController extends DropzoneController
{
private TranslatorInterface $translator;
public function upload(): JsonResponse
{
$request = $this->getRequest();
$response = new EmptyResponse();
$files = $this->getFiles($request->files);
$this->setTranslator($this->container->get('translator'));
if (!empty($files)) {
foreach ($files as $file) {
try {
$this->handleUpload($file, $response, $request);
} catch (UploadException $e) {
$this->errorHandler->addException($response, $e);
} catch (\Exception $e) {
error_log($e);
$error = new UploadException($this->translator->trans('mautic.asset.error.file.failed'));
$this->errorHandler->addException($response, $error);
}
}
} else {
$error = new UploadException($this->translator->trans('mautic.asset.error.file.failed'));
$this->errorHandler->addException($response, $error);
}
return $this->createSupportedJsonResponse($response->assemble());
}
#[\Symfony\Contracts\Service\Attribute\Required]
public function setTranslator(TranslatorInterface $translator): void
{
$this->translator = $translator;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Mautic\AssetBundle\DataFixtures\ORM;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\AssetBundle\Entity\Asset;
class LoadAssetData extends AbstractFixture implements OrderedFixtureInterface
{
public function load(ObjectManager $manager): void
{
$asset = new Asset();
$asset
->setTitle('@TOCHANGE: Asset1 Title')
->setAlias('asset1')
->setOriginalFileName('@TOCHANGE: Asset1 Original File Name')
->setPath('fdb8e28357b02d12d068de3e5661832e21bc08ec.doc')
->setDownloadCount(1)
->setUniqueDownloadCount(1)
->setRevision(1)
->setLanguage('en');
$manager->persist($asset);
$manager->flush();
}
public function getOrder(): int
{
return 10;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticAssetExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace Mautic\AssetBundle\Entity;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<Asset>
*/
class AssetRepository extends CommonRepository
{
use ProjectRepositoryTrait;
/**
* Get a list of entities.
*
* @return Paginator
*/
public function getEntities(array $args = [])
{
$q = $this
->createQueryBuilder('a')
->select('a')
->leftJoin('a.category', 'c');
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* @param string $search
* @param int $limit
* @param int $start
* @param bool|false $viewOther
*
* @return array
*/
public function getAssetList($search = '', $limit = 10, $start = 0, $viewOther = false)
{
$q = $this->createQueryBuilder('a');
$q->select('partial a.{id, title, path, alias, language}');
if (!empty($search)) {
$q->andWhere($q->expr()->like('a.title', ':search'))
->setParameter('search', "%{$search}%");
}
if (!$viewOther) {
$q->andWhere($q->expr()->eq('a.createdBy', ':id'))
->setParameter('id', $this->currentUser->getId());
}
$q->orderBy('a.title');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addCatchAllWhereClause($q, $filter): array
{
return $this->addStandardCatchAllWhereClause($q, $filter, [
'a.title',
'a.alias',
]);
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
[$expr, $parameters] = $this->addStandardSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
$command = $field = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
switch ($command) {
case $this->translator->trans('mautic.asset.asset.searchcommand.isexpired'):
case $this->translator->trans('mautic.asset.asset.searchcommand.isexpired', [], null, 'en_US'):
$expr = sprintf(
"(a.isPublished = :%1\$s AND a.publishDown IS NOT NULL AND a.publishDown <> '' AND a.publishDown < CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.asset.asset.searchcommand.ispending'):
case $this->translator->trans('mautic.asset.asset.searchcommand.ispending', [], null, 'en_US'):
$expr = sprintf(
"(a.isPublished = :%1\$s AND a.publishUp IS NOT NULL AND a.publishUp <> '' AND a.publishUp > CURRENT_TIMESTAMP())",
$unique
);
$forceParameters = [$unique => true];
break;
case $this->translator->trans('mautic.asset.asset.searchcommand.lang'):
$langUnique = $this->generateRandomParameterName();
$langValue = $filter->string.'_%';
$forceParameters = [
$langUnique => $langValue,
$unique => $filter->string,
];
$expr = '('.$q->expr()->eq('a.language', ":$unique").' OR '.$q->expr()->like('a.language', ":$langUnique").')';
$returnParameter = true;
break;
case $this->translator->trans('mautic.project.searchcommand.name'):
case $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US'):
return $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'asset_id',
'asset_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
);
}
if ($expr && $filter->not) {
$expr = $q->expr()->not($expr);
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
} elseif (!$returnParameter) {
$parameters = [];
} else {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.core.searchcommand.isuncategorized',
'mautic.core.searchcommand.ismine',
'mautic.asset.asset.searchcommand.isexpired',
'mautic.asset.asset.searchcommand.ispending',
'mautic.core.searchcommand.category',
'mautic.asset.asset.searchcommand.lang',
'mautic.project.searchcommand.name',
];
return array_merge($commands, parent::getSearchCommands());
}
/**
* @return array<array<string>>
*/
protected function getDefaultOrder(): array
{
return [
['a.title', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'a';
}
/**
* Gets the sum size of assets.
*/
public function getAssetSize(array $assets): int
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('sum(a.size) as total_size')
->from(MAUTIC_TABLE_PREFIX.'assets', 'a')
->where('a.id IN (:assetIds)')
->setParameter('assetIds', $assets, \Doctrine\DBAL\ArrayParameterType::INTEGER);
$result = $q->executeQuery()->fetchAllAssociative();
return (int) $result[0]['total_size'];
}
/**
* @param int $increaseBy
* @param bool|false $unique
*/
public function upDownloadCount($id, $increaseBy = 1, $unique = false): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'assets')
->set('download_count', 'download_count + '.(int) $increaseBy)
->where('id = '.(int) $id);
if ($unique) {
$q->set('unique_download_count', 'unique_download_count + '.(int) $increaseBy);
}
$q->executeStatement();
}
/**
* @param int $categoryId
*
* @return Asset
*
* @throws NoResultException
* @throws NonUniqueResultException
*/
public function getLatestAssetForCategory($categoryId)
{
$q = $this->createQueryBuilder($this->getTableAlias());
$q->where($this->getTableAlias().'.category = :categoryId');
$q->andWhere($this->getTableAlias().'.isPublished = TRUE');
$q->setParameter('categoryId', $categoryId);
$q->orderBy($this->getTableAlias().'.dateAdded', Order::Descending->value);
$q->setMaxResults(1);
return $q->getQuery()->getSingleResult();
}
}

View File

@@ -0,0 +1,425 @@
<?php
namespace Mautic\AssetBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\EmailBundle\Entity\Email;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('asset:assets:viewown')"),
new Get(security: "is_granted('asset:assets:viewown')"),
],
normalizationContext: [
'groups' => ['download:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['asset', 'ipaddress', 'email'],
],
denormalizationContext: [
'groups' => ['download:write'],
'swagger_definition_name' => 'Write',
]
)]
class Download
{
public const TABLE_NAME = 'asset_downloads';
/**
* @var string
*/
#[Groups(['download:read'])]
private $id;
/**
* @var \DateTimeInterface
*/
#[Groups(['download:read', 'download:write'])]
private $dateDownload;
/**
* @var Asset|null
*/
#[Groups(['download:read', 'download:write'])]
private $asset;
/**
* @var IpAddress|null
*/
#[Groups(['download:read', 'download:write'])]
private $ipAddress;
#[Groups(['download:read', 'download:write'])]
private ?Lead $lead;
/**
* @var int
*/
#[Groups(['download:read', 'download:write'])]
private $code;
/**
* @var string|null
*/
#[Groups(['download:read', 'download:write'])]
private $referer;
/**
* @var string
*/
#[Groups(['download:read', 'download:write'])]
private $trackingId;
/**
* @var string|null
*/
#[Groups(['download:read', 'download:write'])]
private $source;
/**
* @var int|null
*/
#[Groups(['download:read', 'download:write'])]
private $sourceId;
/**
* @var Email|null
*/
#[Groups(['download:read', 'download:write'])]
private $email;
private ?string $utmCampaign = null;
private ?string $utmContent = null;
private ?string $utmMedium = null;
private ?string $utmSource = null;
private ?string $utmTerm = null;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(self::TABLE_NAME)
->setCustomRepositoryClass(DownloadRepository::class)
->addIndex(['tracking_id'], 'download_tracking_search')
->addIndex(['source', 'source_id'], 'download_source_search')
->addIndex(['date_download'], 'asset_date_download');
$builder->addBigIntIdField();
$builder->createField('dateDownload', 'datetime')
->columnName('date_download')
->build();
$builder->createManyToOne('asset', 'Asset')
->addJoinColumn('asset_id', 'id', true, false, 'CASCADE')
->build();
$builder->addIpAddress(true);
$builder->addLead(true, 'SET NULL');
$builder->addField('code', 'integer');
$builder->createField('referer', 'text')
->nullable()
->build();
$builder->createField('trackingId', 'string')
->columnName('tracking_id')
->build();
$builder->createField('source', 'string')
->nullable()
->build();
$builder->createField('sourceId', 'integer')
->columnName('source_id')
->nullable()
->build();
$builder->createManyToOne('email', Email::class)
->addJoinColumn('email_id', 'id', true, false, 'SET NULL')
->build();
$builder->createField('utmCampaign', Types::STRING)
->columnName('utm_campaign')
->nullable()
->build();
$builder->createField('utmContent', Types::STRING)
->columnName('utm_content')
->nullable()
->build();
$builder->createField('utmMedium', Types::STRING)
->columnName('utm_medium')
->nullable()
->build();
$builder->createField('utmSource', Types::STRING)
->columnName('utm_source')
->nullable()
->build();
$builder->createField('utmTerm', Types::STRING)
->columnName('utm_term')
->nullable()
->build();
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @param \DateTime $dateDownload
*
* @return Download
*/
public function setDateDownload($dateDownload)
{
$this->dateDownload = $dateDownload;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getDateDownload()
{
return $this->dateDownload;
}
/**
* @param int $code
*
* @return Download
*/
public function setCode($code)
{
$this->code = $code;
return $this;
}
/**
* @return int
*/
public function getCode()
{
return $this->code;
}
/**
* @param string $referer
*
* @return Download
*/
public function setReferer($referer)
{
$this->referer = $referer;
return $this;
}
/**
* @return string
*/
public function getReferer()
{
return $this->referer;
}
/**
* @return Download
*/
public function setAsset(?Asset $asset = null)
{
$this->asset = $asset;
return $this;
}
/**
* @return Asset
*/
public function getAsset()
{
return $this->asset;
}
/**
* @return Download
*/
public function setIpAddress(IpAddress $ipAddress)
{
$this->ipAddress = $ipAddress;
return $this;
}
/**
* @return IpAddress
*/
public function getIpAddress()
{
return $this->ipAddress;
}
/**
* @param int $trackingId
*
* @return Download
*/
public function setTrackingId($trackingId)
{
$this->trackingId = $trackingId;
return $this;
}
/**
* @return int
*/
public function getTrackingId()
{
return $this->trackingId;
}
/**
* @return mixed
*/
public function getLead()
{
return $this->lead;
}
/**
* @param mixed $lead
*/
public function setLead($lead): void
{
$this->lead = $lead;
}
/**
* @return mixed
*/
public function getSource()
{
return $this->source;
}
/**
* @param mixed $source
*/
public function setSource($source): void
{
$this->source = $source;
}
/**
* @return int
*/
public function getSourceId()
{
return $this->sourceId;
}
/**
* @param mixed $sourceId
*/
public function setSourceId($sourceId): void
{
$this->sourceId = (int) $sourceId;
}
/**
* @return mixed
*/
public function getEmail()
{
return $this->email;
}
/**
* @param mixed $email
*/
public function setEmail(Email $email): void
{
$this->email = $email;
}
public function getUtmCampaign(): ?string
{
return $this->utmCampaign;
}
public function setUtmCampaign(?string $utmCampaign): static
{
$this->utmCampaign = $utmCampaign;
return $this;
}
public function getUtmContent(): ?string
{
return $this->utmContent;
}
public function setUtmContent(?string $utmContent): static
{
$this->utmContent = $utmContent;
return $this;
}
public function getUtmMedium(): ?string
{
return $this->utmMedium;
}
public function setUtmMedium(?string $utmMedium): static
{
$this->utmMedium = $utmMedium;
return $this;
}
public function getUtmSource(): ?string
{
return $this->utmSource;
}
public function setUtmSource(?string $utmSource): static
{
$this->utmSource = $utmSource;
return $this;
}
public function getUtmTerm(): ?string
{
return $this->utmTerm;
}
public function setUtmTerm(?string $utmTerm): static
{
$this->utmTerm = $utmTerm;
return $this;
}
}

View File

@@ -0,0 +1,242 @@
<?php
namespace Mautic\AssetBundle\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\Chart\PieChart;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Download>
*/
class DownloadRepository extends CommonRepository
{
use TimelineTrait;
/**
* Determine if the download is a unique download.
*/
public function isUniqueDownload($assetId, $trackingId): bool
{
$q = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q2 = $this->getEntityManager()->getConnection()->createQueryBuilder();
$q2->select('null')
->from(MAUTIC_TABLE_PREFIX.'asset_downloads', 'd');
$q2->where(
$q2->expr()->and(
$q2->expr()->eq('d.tracking_id', ':id'),
$q2->expr()->eq('d.asset_id', (int) $assetId)
)
);
$q->select('u.is_unique')
->from(sprintf('(SELECT (NOT EXISTS (%s)) is_unique)', $q2->getSQL()), 'u'
)
->setParameter('id', $trackingId);
return (bool) $q->executeQuery()->fetchOne();
}
/**
* Get a lead's page downloads.
*
* @param int|null $leadId
*
* @return array
*/
public function getLeadDownloads($leadId = null, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder()
->select('a.id as asset_id, d.date_download as dateDownload, a.title, d.id as download_id, d.lead_id')
->from(MAUTIC_TABLE_PREFIX.'asset_downloads', 'd')
->leftJoin('d', MAUTIC_TABLE_PREFIX.'assets', 'a', 'd.asset_id = a.id');
if ($leadId) {
$query->where('d.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (isset($options['search']) && $options['search']) {
$query->andWhere('a.title LIKE :search')
->setParameter('search', '%'.$options['search'].'%');
}
return $this->getTimelineResults($query, $options, 'a.title', 'd.date_download', [], ['date_download'], null, 'd.id');
}
/**
* Get list of assets ordered by it's download count.
*
* @param QueryBuilder $query
* @param int $limit
* @param int $offset
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getMostDownloaded($query, $limit = 10, $offset = 0): array
{
$query->select('a.title, a.id, count(ad.id) as downloads')
->groupBy('a.id, a.title')
->orderBy('downloads', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
/**
* Get list of asset referrals ordered by it's count.
*
* @param QueryBuilder $query
* @param int $limit
* @param int $offset
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getTopReferrers($query, $limit = 10, $offset = 0): array
{
$query->select('ad.referer, count(ad.referer) as downloads')
->groupBy('ad.referer')
->orderBy('downloads', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $query->executeQuery()->fetchAllAssociative();
}
/**
* Get pie graph data for http statuses.
*
* @param QueryBuilder $query
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getHttpStatuses($query): array
{
$query->select('ad.code as status, count(ad.code) as count')
->groupBy('ad.code')
->orderBy('count', 'DESC');
$results = $query->executeQuery()->fetchAllAssociative();
$chart = new PieChart();
foreach ($results as $result) {
$chart->setDataset($result['status'], $result['count']);
}
return $chart->render();
}
/**
* @return array<mixed, array<string, mixed>>
*/
public function getDownloadCountsByPage($pageId, ?\DateTime $fromDate = null): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(distinct(a.tracking_id)) as count, a.source_id as id, p.title as name, p.hits as total')
->from(MAUTIC_TABLE_PREFIX.'asset_downloads', 'a')
->join('a', MAUTIC_TABLE_PREFIX.'pages', 'p', 'a.source_id = p.id');
if (is_array($pageId)) {
$q->where($q->expr()->in('p.id', $pageId))
->groupBy('p.id, a.source_id, p.title, p.hits');
} else {
$q->where($q->expr()->eq('p.id', ':page'))
->setParameter('page', (int) $pageId);
}
$q->andWhere('a.source = "page"')
->andWhere('a.code = 200');
if (null != $fromDate) {
$dh = new DateTimeHelper($fromDate);
$q->andWhere($q->expr()->gte('a.date_download', ':date'))
->setParameter('date', $dh->toUtcString());
}
$results = $q->executeQuery()->fetchAllAssociative();
$downloads = [];
foreach ($results as $r) {
$downloads[$r['id']] = $r;
}
return $downloads;
}
/**
* Get download count by email by linking emails that have been associated with a page hit that has the
* same tracking ID as an asset download tracking ID and thus assumed happened in the same session.
*
* @return array<mixed, array<string, mixed>>
*/
public function getDownloadCountsByEmail($emailId, ?\DateTime $fromDate = null): array
{
// link email to page hit tracking id to download tracking id
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(distinct(a.tracking_id)) as count, e.id, e.subject as name, e.variant_sent_count as total')
->from(MAUTIC_TABLE_PREFIX.'asset_downloads', 'a')
->join('a', MAUTIC_TABLE_PREFIX.'emails', 'e', 'a.email_id = e.id');
if (is_array($emailId)) {
$q->where($q->expr()->in('e.id', $emailId))
->groupBy('e.id, e.subject, e.variant_sent_count');
} else {
$q->where($q->expr()->eq('e.id', ':email'))
->setParameter('email', (int) $emailId);
}
$q->andWhere('a.code = 200');
if (null != $fromDate) {
$dh = new DateTimeHelper($fromDate);
$q->andWhere($q->expr()->gte('a.date_download', ':date'))
->setParameter('date', $dh->toUtcString());
}
$results = $q->executeQuery()->fetchAllAssociative();
$downloads = [];
foreach ($results as $r) {
$downloads[$r['id']] = $r;
}
return $downloads;
}
public function updateLeadByTrackingId($leadId, $newTrackingId, $oldTrackingId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'asset_downloads')
->set('lead_id', (int) $leadId)
->set('tracking_id', ':newTrackingId')
->where(
$q->expr()->eq('tracking_id', ':oldTrackingId')
)
->setParameters([
'newTrackingId' => $newTrackingId,
'oldTrackingId' => $oldTrackingId,
])
->executeStatement();
}
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'asset_downloads')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Mautic\AssetBundle\ErrorHandler;
use Oneup\UploaderBundle\Uploader\ErrorHandler\ErrorHandlerInterface;
use Oneup\UploaderBundle\Uploader\Response\AbstractResponse;
class DropzoneErrorHandler implements ErrorHandlerInterface
{
public function addException(AbstractResponse $response, \Exception $exception): void
{
// HTTP status between 400 and 500 should be set here.
// Dropzone will handle error messages itself then.
// Unfortunatelly UploaderBundle will have this option in v 3.
$response['error'] = $exception->getMessage();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Mautic\AssetBundle\Event;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\CoreBundle\Event\CommonEvent;
class AssetEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(Asset $asset, $isNew = false)
{
$this->entity = $asset;
$this->isNew = $isNew;
}
/**
* Returns the Asset entity.
*
* @return Asset
*/
public function getAsset()
{
return $this->entity;
}
/**
* Sets the Asset entity.
*/
public function setAsset(Asset $asset): void
{
$this->entity = $asset;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
final class AssetExportListEvent extends CommonEvent
{
/**
* @var array<string>
*/
private array $list = [];
/**
* @param list<array<string, array<string, mixed>>> $data
*/
public function __construct(private array $data)
{
}
/**
* @return list<array<string, array<string, mixed>>>
*/
public function getEntityData(): array
{
return $this->data;
}
public function setList(string $item): void
{
if (!in_array($item, $this->list)) {
$this->list[] = $item;
}
}
/**
* @return array<string>|null
*/
public function getList(): ?array
{
return $this->list ?? null;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Mautic\AssetBundle\Event;
use Mautic\AssetBundle\Entity\Download;
use Mautic\CoreBundle\Event\CommonEvent;
class AssetLoadEvent extends CommonEvent
{
public function __construct(
Download $download,
protected bool $unique,
) {
$this->entity = $download;
}
/**
* Returns the Download entity.
*
* @return Download
*/
public function getRecord()
{
return $this->entity;
}
/**
* @return \Mautic\AssetBundle\Entity\Asset
*/
public function getAsset()
{
return $this->entity->getAsset();
}
/**
* Returns if this is the first download for the session.
*/
public function isUnique(): bool
{
return $this->unique;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Mautic\AssetBundle\Event;
use Gaufrette\Adapter;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\PluginBundle\Integration\UnifiedIntegrationInterface;
class RemoteAssetBrowseEvent extends CommonEvent
{
private ?Adapter $adapter = null;
private ?string $failureMessage = null;
public function __construct(
private UnifiedIntegrationInterface $integration,
) {
}
public function getAdapter(): ?Adapter
{
return $this->adapter;
}
public function getIntegration(): UnifiedIntegrationInterface
{
return $this->integration;
}
public function setAdapter(Adapter $adapter): void
{
$this->adapter = $adapter;
}
public function setFailed(string $message): void
{
$this->failureMessage = $message;
}
public function getFailureMessage(): ?string
{
return $this->failureMessage;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Event\AssetExportListEvent;
use Mautic\CoreBundle\Helper\PathsHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class AssetExportListEventSubscriber implements EventSubscriberInterface
{
public function __construct(private PathsHelper $pathsHelper)
{
}
public static function getSubscribedEvents(): array
{
return [
AssetExportListEvent::class => ['onExportList', 0],
];
}
public function onExportList(AssetExportListEvent $event): void
{
$data = $event->getEntityData();
if (empty($data)) {
return;
}
foreach ($event->getEntityData() as $section) {
if (!is_array($section)) {
continue;
}
if (!isset($section[Asset::ENTITY_NAME]) || !is_array($section[Asset::ENTITY_NAME])) {
continue;
}
foreach ($section[Asset::ENTITY_NAME] as $asset) {
$location = $asset['storage_location'] ?? null;
$path = $asset['path'] ?? null;
if ('local' === $location && !empty($path)) {
$assetPath = $this->pathsHelper->getSystemPath('media').'/files/'.$path;
$event->setList($assetPath);
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Event\EntityExportEvent;
use Mautic\CoreBundle\Event\EntityImportAnalyzeEvent;
use Mautic\CoreBundle\Event\EntityImportEvent;
use Mautic\CoreBundle\Event\EntityImportUndoEvent;
use Mautic\CoreBundle\EventListener\ImportExportTrait;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class AssetImportExportSubscriber implements EventSubscriberInterface
{
use ImportExportTrait;
public function __construct(
private AssetModel $assetModel,
private EntityManagerInterface $entityManager,
private AuditLogModel $auditLogModel,
private IpLookupHelper $ipLookupHelper,
private DenormalizerInterface $serializer,
) {
}
public static function getSubscribedEvents(): array
{
return [
EntityExportEvent::class => ['onAssetExport', 0],
EntityImportEvent::class => ['onAssetImport', 0],
EntityImportUndoEvent::class => ['onUndoImport', 0],
EntityImportAnalyzeEvent::class => ['onDuplicationCheck', 0],
];
}
public function onAssetExport(EntityExportEvent $event): void
{
if (Asset::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$assetId = $event->getEntityId();
$asset = $this->assetModel->getEntity($assetId);
if (!$asset) {
return;
}
$assetData = [
'id' => $asset->getId(),
'is_published' => $asset->isPublished(),
'title' => $asset->getTitle(),
'description' => $asset->getDescription(),
'alias' => $asset->getAlias(),
'storage_location' => $asset->getStorageLocation(),
'path' => $asset->getPath(),
'remote_path' => $asset->getRemotePath(),
'original_file_name' => $asset->getOriginalFileName(),
'lang' => $asset->getLanguage(),
'publish_up' => $asset->getPublishUp() ? $asset->getPublishUp()->format(DATE_ATOM) : null,
'publish_down' => $asset->getPublishDown() ? $asset->getPublishDown()->format(DATE_ATOM) : null,
'extension' => $asset->getExtension(),
'mime' => $asset->getMime(),
'size' => (int) $asset->getSize(),
'disallow' => $asset->getDisallow(),
'uuid' => $asset->getUuid(),
];
$event->addEntity(Asset::ENTITY_NAME, $assetData);
$log = [
'bundle' => 'asset',
'object' => 'asset',
'objectId' => $asset->getId(),
'action' => 'export',
'details' => $assetData,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
public function onAssetImport(EntityImportEvent $event): void
{
if (Asset::ENTITY_NAME !== $event->getEntityName() || !$event->getEntityData()) {
return;
}
$stats = [
EntityImportEvent::NEW => ['names' => [], 'ids' => [], 'count' => 0],
EntityImportEvent::UPDATE => ['names' => [], 'ids' => [], 'count' => 0],
];
foreach ($event->getEntityData() as $element) {
$object = $this->entityManager->getRepository(Asset::class)->findOneBy(['uuid' => $element['uuid']]);
$isNew = !$object;
$object ??= new Asset();
if (isset($element['size'])) {
$element['size'] = (int) $element['size'];
}
$this->serializer->denormalize(
$element,
Asset::class,
null,
['object_to_populate' => $object]
);
$this->assetModel->saveEntity($object);
$event->addEntityIdMap((int) $element['id'], $object->getId());
$status = $isNew ? EntityImportEvent::NEW : EntityImportEvent::UPDATE;
$stats[$status]['names'][] = $object->getTitle();
$stats[$status]['ids'][] = $object->getId();
++$stats[$status]['count'];
$this->logAction('import', $object->getId(), $element);
}
foreach ($stats as $status => $info) {
if ($info['count'] > 0) {
$event->setStatus($status, [Asset::ENTITY_NAME => $info]);
}
}
}
public function onUndoImport(EntityImportUndoEvent $event): void
{
if (Asset::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$summary = $event->getSummary();
if (!isset($summary['ids']) || empty($summary['ids'])) {
return;
}
foreach ($summary['ids'] as $id) {
$entity = $this->entityManager->getRepository(Asset::class)->find($id);
if ($entity) {
$this->entityManager->remove($entity);
$this->logAction('undo_import', $id, ['deletedEntity' => Asset::class]);
}
}
$this->entityManager->flush();
}
public function onDuplicationCheck(EntityImportAnalyzeEvent $event): void
{
$this->performDuplicationCheck(
$event,
Asset::ENTITY_NAME,
Asset::class,
'title',
$this->entityManager
);
}
/**
* @param array<string, mixed> $details
*/
private function logAction(string $action, int $objectId, array $details): void
{
$this->auditLogModel->writeToLog([
'bundle' => 'asset',
'object' => 'asset',
'objectId' => $objectId,
'action' => $action,
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Event as Events;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class AssetSubscriber implements EventSubscriberInterface
{
public function __construct(
private IpLookupHelper $ipLookupHelper,
private AuditLogModel $auditLogModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
AssetEvents::ASSET_POST_SAVE => ['onAssetPostSave', 0],
AssetEvents::ASSET_POST_DELETE => ['onAssetDelete', 0],
];
}
/**
* Add an entry to the audit log.
*/
public function onAssetPostSave(Events\AssetEvent $event): void
{
$asset = $event->getAsset();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'asset',
'object' => 'asset',
'objectId' => $asset->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onAssetDelete(Events\AssetEvent $event): void
{
$asset = $event->getAsset();
$log = [
'bundle' => 'asset',
'object' => 'asset',
'objectId' => $asset->deletedId,
'action' => 'delete',
'details' => ['name' => $asset->getTitle()],
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
];
$this->auditLogModel->writeToLog($log);
// In case of batch delete, this method call remove the uploaded file
$asset->removeUpload();
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Helper\TokenHelper;
use Mautic\CoreBundle\Event\BuilderEvent;
use Mautic\CoreBundle\Helper\BuilderTokenHelperFactory;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailSendEvent;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class BuilderSubscriber implements EventSubscriberInterface
{
private string $assetToken = '{assetlink=(.*?)}';
public function __construct(
private CorePermissions $security,
private TokenHelper $tokenHelper,
private ContactTracker $contactTracker,
private BuilderTokenHelperFactory $builderTokenHelperFactory,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_ON_BUILD => ['onBuilderBuild', 0],
EmailEvents::EMAIL_ON_SEND => ['onEmailGenerate', 0],
EmailEvents::EMAIL_ON_DISPLAY => ['onEmailGenerate', 0],
PageEvents::PAGE_ON_BUILD => ['onBuilderBuild', 0],
PageEvents::PAGE_ON_DISPLAY => ['onPageDisplay', 0],
];
}
public function onBuilderBuild(BuilderEvent $event): void
{
if ($event->tokensRequested($this->assetToken)) {
$tokenHelper = $this->builderTokenHelperFactory->getBuilderTokenHelper('asset');
$event->addTokensFromHelper($tokenHelper, $this->assetToken, 'title', 'id', true);
}
}
public function onEmailGenerate(EmailSendEvent $event): void
{
$lead = $event->getLead();
$leadId = (int) (null !== $lead ? $lead['id'] : null);
$email = $event->getEmail();
$tokens = $this->generateTokensFromContent($event, $leadId, $event->getSource(), null === $email ? null : $email->getId());
$event->addTokens($tokens);
}
public function onPageDisplay(PageDisplayEvent $event): void
{
if (!$lead = $event->getLead()) {
$lead = $this->security->isAnonymous() ? $this->contactTracker->getContact() : null;
}
$leadId = $lead ? $lead->getId() : null;
$page = $event->getPage();
$tokens = $this->generateTokensFromContent($event, $leadId, ['page', $page->getId()]);
$content = $event->getContent();
if ([] !== $tokens) {
$content = str_ireplace(array_keys($tokens), $tokens, $content);
}
$event->setContent($content);
}
/**
* @param PageDisplayEvent|EmailSendEvent $event
* @param array $source
* @param int|null $emailId
*
* @return mixed[]
*/
private function generateTokensFromContent($event, ?int $leadId, $source = [], $emailId = null): array
{
if ($event instanceof PageDisplayEvent || ($event instanceof EmailSendEvent && $event->shouldAppendClickthrough())) {
$clickthrough = [
'source' => $source,
'lead' => $leadId ?? false,
'email' => $emailId ?? false,
];
}
return $this->tokenHelper->findAssetTokens($event->getContent(), array_filter($clickthrough ?? []));
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Event\AssetLoadEvent;
use Mautic\AssetBundle\Form\Type\CampaignEventAssetDownloadType;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CampaignSubscriber implements EventSubscriberInterface
{
public function __construct(
private RealTimeExecutioner $realTimeExecutioner,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
AssetEvents::ASSET_ON_LOAD => ['onAssetDownload', 0],
AssetEvents::ON_CAMPAIGN_TRIGGER_DECISION => ['onCampaignTriggerDecision', 0],
];
}
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
$trigger = [
'label' => 'mautic.asset.campaign.event.download',
'description' => 'mautic.asset.campaign.event.download_descr',
'eventName' => AssetEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'formType' => CampaignEventAssetDownloadType::class,
'channel' => 'asset',
'channelIdField' => 'assets',
];
$event->addDecision('asset.download', $trigger);
}
/**
* Trigger point actions for asset download.
*/
public function onAssetDownload(AssetLoadEvent $event): void
{
$asset = $event->getRecord()->getAsset();
if (null !== $asset) {
$this->realTimeExecutioner->execute('asset.download', $asset, 'asset', $asset->getId());
}
}
public function onCampaignTriggerDecision(CampaignExecutionEvent $event)
{
$eventDetails = $event->getEventDetails();
if (null == $eventDetails) {
return $event->setResult(true);
}
$assetId = $eventDetails->getId();
$limitToAssets = $event->getConfig()['assets'];
if (!empty($limitToAssets) && !in_array($assetId, $limitToAssets)) {
// no points change
return $event->setResult(false);
}
$event->setResult(true);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Form\Type\ConfigType;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_ON_GENERATE => ['onConfigGenerate', 0],
];
}
public function onConfigGenerate(ConfigBuilderEvent $event): void
{
$event->addForm([
'bundle' => 'AssetBundle',
'formAlias' => 'assetconfig',
'formType' => ConfigType::class,
'formTheme' => '@MauticAsset/FormTheme/Config/_config_assetconfig_widget.html.twig',
'parameters' => $event->getParametersFromConfig('MauticAssetBundle'),
]);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\EventListener\DashboardSubscriber as MainDashboardSubscriber;
use Symfony\Component\Routing\RouterInterface;
class DashboardSubscriber extends MainDashboardSubscriber
{
/**
* Define the name of the bundle/category of the widget(s).
*
* @var string
*/
protected $bundle = 'asset';
/**
* Define the widget(s).
*
* @var array
*/
protected $types = [
'asset.downloads.in.time' => [],
'unique.vs.repetitive.downloads' => [],
'popular.assets' => [],
'created.assets' => [],
];
/**
* Define permissions to see those widgets.
*
* @var array
*/
protected $permissions = [
'asset:assets:viewown',
'asset:assets:viewother',
];
public function __construct(
protected AssetModel $assetModel,
protected RouterInterface $router,
) {
}
/**
* Set a widget detail when needed.
*/
public function onWidgetDetailGenerate(WidgetDetailEvent $event): void
{
$this->checkPermissions($event);
$canViewOthers = $event->hasPermission('asset:assets:viewother');
if ('asset.downloads.in.time' == $event->getType()) {
$widget = $event->getWidget();
$params = $widget->getParams();
if (!$event->isCached()) {
$event->setTemplateData([
'chartType' => 'line',
'chartHeight' => $widget->getHeight() - 80,
'chartData' => $this->assetModel->getDownloadsLineChartData(
$params['timeUnit'],
$params['dateFrom'],
$params['dateTo'],
$params['dateFormat'],
$canViewOthers
),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('unique.vs.repetitive.downloads' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
$event->setTemplateData([
'chartType' => 'pie',
'chartHeight' => $event->getWidget()->getHeight() - 80,
'chartData' => $this->assetModel->getUniqueVsRepetitivePieChartData($params['dateFrom'], $params['dateTo'], $canViewOthers),
]);
}
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
if ('popular.assets' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
if (empty($params['limit'])) {
// Count the pages limit from the widget height
$limit = round((($event->getWidget()->getHeight() - 80) / 35) - 1);
} else {
$limit = $params['limit'];
}
$assets = $this->assetModel->getPopularAssets($limit, $params['dateFrom'], $params['dateTo'], $canViewOthers);
$items = [];
// Build table rows with links
foreach ($assets as &$asset) {
$assetUrl = $this->router->generate('mautic_asset_action', ['objectAction' => 'view', 'objectId' => $asset['id']]);
$row = [
[
'value' => $asset['title'],
'type' => 'link',
'link' => $assetUrl,
],
[
'value' => $asset['download_count'],
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
'mautic.dashboard.label.downloads',
],
'bodyItems' => $items,
'raw' => $assets,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
if ('created.assets' == $event->getType()) {
if (!$event->isCached()) {
$params = $event->getWidget()->getParams();
if (empty($params['limit'])) {
// Count the assets limit from the widget height
$limit = round((($event->getWidget()->getHeight() - 80) / 35) - 1);
} else {
$limit = $params['limit'];
}
$assets = $this->assetModel->getAssetList($limit, $params['dateFrom'], $params['dateTo'], [], ['canViewOthers' => $canViewOthers]);
$items = [];
// Build table rows with links
foreach ($assets as &$asset) {
$assetUrl = $this->router->generate('mautic_asset_action', ['objectAction' => 'view', 'objectId' => $asset['id']]);
$row = [
[
'value' => $asset['name'],
'type' => 'link',
'link' => $assetUrl,
],
];
$items[] = $row;
}
$event->setTemplateData([
'headItems' => [
'mautic.dashboard.label.title',
],
'bodyItems' => $items,
'raw' => $assets,
]);
}
$event->setTemplate('@MauticCore/Helper/table.html.twig');
$event->stopPropagation();
}
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Entity\Download;
use Mautic\CoreBundle\Event\DetermineWinnerEvent;
use Mautic\EmailBundle\Entity\Email;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class DetermineWinnerSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $em,
private TranslatorInterface $translator,
) {
}
public static function getSubscribedEvents(): array
{
return [
AssetEvents::ON_DETERMINE_DOWNLOAD_RATE_WINNER => ['onDetermineDownloadRateWinner', 0],
];
}
/**
* Determines the winner of A/B test based on number of asset downloads.
*/
public function onDetermineDownloadRateWinner(DetermineWinnerEvent $event): void
{
$repo = $this->em->getRepository(Download::class);
$parameters = $event->getParameters();
$parent = $parameters['parent'];
$children = $parameters['children'];
// if this is an email A/B test, then link email to page to form submission
// if it is a page A/B test, then link form submission to page
$type = ($parent instanceof Email) ? 'email' : 'page';
$ids = [$parent->getId()];
foreach ($children as $c) {
if ($c->isPublished()) {
$id = $c->getId();
$ids[] = $id;
}
}
$startDate = $parent->getVariantStartDate();
if (null != $startDate && !empty($ids)) {
$counts = ('page' == $type) ? $repo->getDownloadCountsByPage($ids, $startDate) : $repo->getDownloadCountsByEmail($ids, $startDate);
$translator = $this->translator;
if ($counts) {
$downloads = $support = $data = [];
$hasResults = [];
$downloadsLabel = $translator->trans('mautic.asset.abtest.label.downloads');
$hitsLabel = ('page' == $type) ? $translator->trans('mautic.asset.abtest.label.hits') : $translator->trans('mautic.asset.abtest.label.sentemils');
foreach ($counts as $stats) {
$rate = ($stats['total']) ? round(($stats['count'] / $stats['total']) * 100, 2) : 0;
$downloads[$stats['id']] = $rate;
$data[$downloadsLabel][] = $stats['count'];
$data[$hitsLabel][] = $stats['total'];
$support['labels'][] = $stats['id'].':'.$stats['name'].' ('.$rate.'%)';
$hasResults[] = $stats['id'];
}
// make sure that parent and published children are included
if (!in_array($parent->getId(), $hasResults)) {
$data[$downloadsLabel][] = 0;
$data[$hitsLabel][] = 0;
$support['labels'][] = $parent->getId().':'.(('page' == $type) ? $parent->getTitle() : $parent->getName()).' (0%)';
}
foreach ($children as $c) {
if ($c->isPublished()) {
if (!in_array($c->getId(), $hasResults)) {
$data[$downloadsLabel][] = 0;
$data[$hitsLabel][] = 0;
$support['labels'][] = $c->getId().':'.(('page' == $type) ? $c->getTitle() : $c->getName()).' (0%)';
}
}
}
$support['data'] = $data;
// set max for scales
$maxes = [];
foreach ($support['data'] as $data) {
$maxes[] = max($data);
}
$top = max($maxes);
$support['step_width'] = (ceil($top / 10) * 10);
// put in order from least to greatest just because
asort($downloads);
// who's the winner?
$max = max($downloads);
// get the page ids with the most number of downloads
$winners = ($max > 0) ? array_keys($downloads, $max) : [];
$event->setAbTestResults([
'winners' => $winners,
'support' => $support,
'basedOn' => 'asset.downloads',
'supportTemplate' => '@MauticPage/SubscribedEvents/AbTest/bargraph.html.twig',
]);
return;
}
}
$event->setAbTestResults([
'winners' => [],
'support' => [],
'basedOn' => 'asset.downloads',
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\AssetEvents;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event\EmailBuilderEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class EmailSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_ON_BUILD => ['onEmailBuild', 0],
];
}
public function onEmailBuild(EmailBuilderEvent $event): void
{
if ($event->abTestWinnerCriteriaRequested()) {
// add AB Test Winner Criteria
$formSubmissions = [
'group' => 'mautic.asset.abtest.criteria',
'label' => 'mautic.asset.abtest.criteria.downloads',
'event' => AssetEvents::ON_DETERMINE_DOWNLOAD_RATE_WINNER,
];
$event->addAbTestWinnerCriteria('asset.downloads', $formSubmissions);
}
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Form\Type\FormSubmitActionDownloadFileType;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\ThemeHelperInterface;
use Mautic\CoreBundle\Twig\Helper\AnalyticsHelper;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Mautic\FormBundle\Entity\Form;
use Mautic\FormBundle\Event\FormBuilderEvent;
use Mautic\FormBundle\Event\SubmissionEvent;
use Mautic\FormBundle\FormEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
class FormSubscriber implements EventSubscriberInterface
{
public function __construct(
private AssetModel $assetModel,
protected TranslatorInterface $translator,
private AnalyticsHelper $analyticsHelper,
private AssetsHelper $assetsHelper,
private ThemeHelperInterface $themeHelper,
private Environment $twig,
private CoreParametersHelper $coreParametersHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
FormEvents::FORM_ON_BUILD => ['onFormBuilder', 0],
FormEvents::ON_EXECUTE_SUBMIT_ACTION => [
['onFormSubmitActionAssetDownload', 0],
['onFormSubmitActionDownloadFile', 0],
],
];
}
/**
* Add a lead generation action to available form submit actions.
*/
public function onFormBuilder(FormBuilderEvent $event): void
{
$event->addSubmitAction('asset.download', [
'group' => 'mautic.asset.actions',
'label' => 'mautic.asset.asset.submitaction.downloadfile',
'description' => 'mautic.asset.asset.submitaction.downloadfile_descr',
'formType' => FormSubmitActionDownloadFileType::class,
'formTypeCleanMasks' => ['message' => 'html'],
'eventName' => FormEvents::ON_EXECUTE_SUBMIT_ACTION,
'allowCampaignForm' => true,
'template' => '@MauticAsset/Action/asset.html.twig',
]);
}
public function onFormSubmitActionAssetDownload(SubmissionEvent $event): void
{
if (false === $event->checkContext('asset.download')) {
return;
}
$properties = $event->getAction()->getProperties();
$assetId = $properties['asset'];
$categoryId = $properties['category'] ?? null;
$asset = null;
if (null !== $assetId) {
$asset = $this->assetModel->getEntity($assetId);
} elseif (null !== $categoryId) {
try {
$asset = $this->assetModel->getRepository()->getLatestAssetForCategory($categoryId);
} catch (NoResultException|NonUniqueResultException) {
$asset = null;
}
}
if ($asset instanceof Asset && $asset->isPublished()) {
$event->setPostSubmitCallback('asset.download_file', [
'eventName' => FormEvents::ON_EXECUTE_SUBMIT_ACTION,
'form' => $event->getAction()->getForm(),
'asset' => $asset,
'message' => $properties['message'] ?? '',
]);
}
}
public function onFormSubmitActionDownloadFile(SubmissionEvent $event): void
{
if (false === $event->checkContext('asset.download_file')) {
return;
}
/*
* No further actions can run after this, as we need to send the
* download response to the client.
*/
$event->stopPropagation();
/**
* @var Form $form
* @var Asset $asset
* @var string $message
* @var bool $messengerMode
*/
[
'form' => $form,
'asset' => $asset,
'message' => $message,
'messengerMode' => $messengerMode,
] = $event->getPostSubmitCallback('asset.download_file');
$url = $this->assetModel->generateUrl($asset, true, [
'lead' => $event->getLead() ? $event->getLead()->getId() : null,
'channel' => ['form' => $form->getId()],
]).'&stream=0';
if ($messengerMode) {
$event->setPostSubmitResponse(['download' => $url]);
return;
}
$msg = $message.$this->translator->trans('mautic.asset.asset.submitaction.downloadfile.msg', [
'%url%' => $url,
]);
$analytics = $this->analyticsHelper->getCode();
if (!empty($analytics)) {
$this->assetsHelper->addCustomDeclaration($analytics);
}
$event->setPostSubmitResponse(new Response(
$this->twig->render(
$this->themeHelper->checkForTwigTemplate('@themes/'.$this->coreParametersHelper->get('theme').'/html/message.html.twig'),
[
'message' => $msg,
'type' => 'notice',
'template' => $this->coreParametersHelper->get('theme'),
]
)
));
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Entity\DownloadRepository;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\LeadBundle\Event\LeadChangeEvent;
use Mautic\LeadBundle\Event\LeadMergeEvent;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private AssetModel $assetModel,
private TranslatorInterface $translator,
private RouterInterface $router,
private DownloadRepository $downloadRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::TIMELINE_ON_GENERATE => ['onTimelineGenerate', 0],
LeadEvents::CURRENT_LEAD_CHANGED => ['onLeadChange', 0],
LeadEvents::LEAD_POST_MERGE => ['onLeadMerge', 0],
];
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
// Set available event types
$eventTypeKey = 'asset.download';
$eventTypeName = $this->translator->trans('mautic.asset.event.download');
$event->addEventType($eventTypeKey, $eventTypeName);
$event->addSerializerGroup('assetList');
// Decide if those events are filtered
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$downloads = $this->downloadRepository->getLeadDownloads($event->getLeadId(), $event->getQueryOptions());
// Add total number to counter
$event->addToCounter($eventTypeKey, $downloads);
if (!$event->isEngagementCount()) {
// Add the downloads to the event array
foreach ($downloads['results'] as $download) {
$asset = $this->assetModel->getEntity($download['asset_id']);
$event->addEvent(
[
'event' => $eventTypeKey,
'eventId' => $eventTypeKey.$download['download_id'],
'eventLabel' => [
'label' => $download['title'],
'href' => $this->router->generate('mautic_asset_action', ['objectAction' => 'view', 'objectId' => $download['asset_id']]),
],
'extra' => [
'asset' => $asset,
'assetDownloadUrl' => $this->assetModel->generateUrl($asset),
],
'eventType' => $eventTypeName,
'timestamp' => $download['dateDownload'],
'icon' => 'ri-download-line',
'contentTemplate' => '@MauticAsset/SubscribedEvents/Timeline/index.html.twig',
'contactId' => $download['lead_id'],
]
);
}
}
}
public function onLeadChange(LeadChangeEvent $event): void
{
$this->assetModel->getDownloadRepository()->updateLeadByTrackingId(
$event->getNewLead()->getId(),
$event->getNewTrackingId(),
$event->getOldTrackingId()
);
}
public function onLeadMerge(LeadMergeEvent $event): void
{
$this->assetModel->getDownloadRepository()->updateLead($event->getLoser()->getId(), $event->getVictor()->getId());
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\AssetEvents;
use Mautic\PageBundle\Event\PageBuilderEvent;
use Mautic\PageBundle\PageEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class PageSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
PageEvents::PAGE_ON_BUILD => ['OnPageBuild', 0],
];
}
/**
* Add forms to available page tokens.
*/
public function onPageBuild(PageBuilderEvent $event): void
{
if ($event->abTestWinnerCriteriaRequested()) {
// add AB Test Winner Criteria
$assetDownloads = [
'group' => 'mautic.asset.abtest.criteria',
'label' => 'mautic.asset.abtest.criteria.downloads',
'event' => AssetEvents::ON_DETERMINE_DOWNLOAD_RATE_WINNER,
];
$event->addAbTestWinnerCriteria('asset.downloads', $assetDownloads);
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Event\AssetLoadEvent;
use Mautic\AssetBundle\Form\Type\PointActionAssetDownloadType;
use Mautic\PointBundle\Event\PointBuilderEvent;
use Mautic\PointBundle\Model\PointModel;
use Mautic\PointBundle\PointEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class PointSubscriber implements EventSubscriberInterface
{
public function __construct(
private PointModel $pointModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
PointEvents::POINT_ON_BUILD => ['onPointBuild', 0],
AssetEvents::ASSET_ON_LOAD => ['onAssetDownload', 0],
];
}
public function onPointBuild(PointBuilderEvent $event): void
{
$action = [
'group' => 'mautic.asset.actions',
'label' => 'mautic.asset.point.action.download',
'description' => 'mautic.asset.point.action.download_descr',
'callback' => [\Mautic\AssetBundle\Helper\PointActionHelper::class, 'validateAssetDownload'],
'formType' => PointActionAssetDownloadType::class,
];
$event->addAction('asset.download', $action);
}
/**
* Trigger point actions for asset download.
*/
public function onAssetDownload(AssetLoadEvent $event): void
{
$asset = $event->getRecord()->getAsset();
if (null !== $asset) {
$this->pointModel->triggerAction('asset.download', $asset);
}
}
}

View File

@@ -0,0 +1,285 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Entity\DownloadRepository;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\LeadBundle\Model\CompanyReportData;
use Mautic\LeadBundle\Report\DncReportService;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportDataEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\Event\ReportGraphEvent;
use Mautic\ReportBundle\ReportEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ReportSubscriber implements EventSubscriberInterface
{
public const CONTEXT_ASSET = 'assets';
public const CONTEXT_ASSET_DOWNLOAD = 'asset.downloads';
public function __construct(
private CompanyReportData $companyReportData,
private DownloadRepository $downloadRepository,
private DncReportService $dncReportService,
) {
}
public static function getSubscribedEvents(): array
{
return [
ReportEvents::REPORT_ON_BUILD => ['onReportBuilder', 0],
ReportEvents::REPORT_ON_GENERATE => ['onReportGenerate', 0],
ReportEvents::REPORT_ON_GRAPH_GENERATE => ['onReportGraphGenerate', 0],
ReportEvents::REPORT_ON_DISPLAY => ['onReportDisplay', 0],
];
}
/**
* Add available tables and columns to the report builder lookup.
*/
public function onReportBuilder(ReportBuilderEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_ASSET, self::CONTEXT_ASSET_DOWNLOAD])) {
return;
}
// Assets
$prefix = 'a.';
$columns = [
$prefix.'download_count' => [
'alias' => 'download_count',
'label' => 'mautic.asset.report.download_count',
'type' => 'int',
],
$prefix.'unique_download_count' => [
'alias' => 'unique_download_count',
'label' => 'mautic.asset.report.unique_download_count',
'type' => 'int',
],
$prefix.'alias' => [
'label' => 'mautic.core.alias',
'type' => 'string',
],
$prefix.'lang' => [
'label' => 'mautic.core.language',
'type' => 'string',
],
$prefix.'title' => [
'label' => 'mautic.core.title',
'type' => 'string',
],
];
$columns = array_merge(
$columns,
$event->getStandardColumns($prefix, ['name'], 'mautic_asset_action'),
$event->getCategoryColumns()
);
$event->addTable(
self::CONTEXT_ASSET,
[
'display_name' => 'mautic.asset.assets',
'columns' => $columns,
]
);
if ($event->checkContext([self::CONTEXT_ASSET_DOWNLOAD])) {
// asset downloads calculate this columns
$columns[$prefix.'download_count']['formula'] = 'COUNT(ad.id)';
$columns[$prefix.'unique_download_count']['formula'] = 'COUNT(DISTINCT ad.lead_id)';
// Downloads
$downloadPrefix = 'ad.';
$downloadColumns = [
$downloadPrefix.'date_download' => [
'label' => 'mautic.asset.report.download.date_download',
'type' => 'datetime',
'groupByFormula' => 'DATE('.$downloadPrefix.'date_download)',
],
$downloadPrefix.'code' => [
'label' => 'mautic.asset.report.download.code',
'type' => 'string',
],
$downloadPrefix.'referer' => [
'label' => 'mautic.core.referer',
'type' => 'string',
],
$downloadPrefix.'source' => [
'label' => 'mautic.report.field.source',
'type' => 'string',
],
$downloadPrefix.'source_id' => [
'label' => 'mautic.report.field.source_id',
'type' => 'int',
],
$downloadPrefix.'utm_campaign' => [
'label' => 'mautic.report.field.utm_campaign',
'type' => 'string',
],
$downloadPrefix.'utm_content' => [
'label' => 'mautic.report.field.utm_content',
'type' => 'string',
],
$downloadPrefix.'utm_medium' => [
'label' => 'mautic.report.field.utm_medium',
'type' => 'string',
],
$downloadPrefix.'utm_source' => [
'label' => 'mautic.report.field.utm_source',
'type' => 'string',
],
$downloadPrefix.'utm_term' => [
'label' => 'mautic.report.field.utm_term',
'type' => 'string',
],
];
$companyColumns = $this->companyReportData->getCompanyData();
$commonColumnsAndFilters = array_merge(
$columns,
$downloadColumns,
$event->getCampaignByChannelColumns(),
$event->getLeadColumns(),
$event->getIpColumn(),
$companyColumns
);
$assetDownloadColumns = array_merge($commonColumnsAndFilters, $this->dncReportService->getDncColumns());
$assetDownloadFilters = array_merge($commonColumnsAndFilters, $this->dncReportService->getDncFilters());
$event->addTable(
self::CONTEXT_ASSET_DOWNLOAD,
[
'display_name' => 'mautic.asset.report.downloads.table',
'columns' => $assetDownloadColumns,
'filters' => $assetDownloadFilters,
],
self::CONTEXT_ASSET
);
// Add Graphs
$context = self::CONTEXT_ASSET_DOWNLOAD;
$event->addGraph($context, 'line', 'mautic.asset.graph.line.downloads');
$event->addGraph($context, 'table', 'mautic.asset.table.most.downloaded');
$event->addGraph($context, 'table', 'mautic.asset.table.top.referrers');
$event->addGraph($context, 'pie', 'mautic.asset.graph.pie.statuses', ['translate' => false]);
}
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGenerate(ReportGeneratorEvent $event): void
{
if (!$event->checkContext([self::CONTEXT_ASSET, self::CONTEXT_ASSET_DOWNLOAD])) {
return;
}
$queryBuilder = $event->getQueryBuilder();
if ($event->checkContext(self::CONTEXT_ASSET)) {
$queryBuilder->from(MAUTIC_TABLE_PREFIX.'assets', 'a');
$event->addCategoryLeftJoin($queryBuilder, 'a');
} elseif ($event->checkContext(self::CONTEXT_ASSET_DOWNLOAD)) {
$event->applyDateFilters($queryBuilder, 'date_download', 'ad');
$queryBuilder->from(MAUTIC_TABLE_PREFIX.'asset_downloads', 'ad')
->leftJoin('ad', MAUTIC_TABLE_PREFIX.'assets', 'a', 'a.id = ad.asset_id');
$event->addCategoryLeftJoin($queryBuilder, 'a');
$event->addLeadLeftJoin($queryBuilder, 'ad');
$event->addIpAddressLeftJoin($queryBuilder, 'ad');
$event->addCampaignByChannelJoin($queryBuilder, 'a', 'asset');
if ($this->companyReportData->eventHasCompanyColumns($event)) {
$event->addCompanyLeftJoin($queryBuilder);
}
if (!$event->hasGroupBy()) {
$queryBuilder->groupBy('ad.id');
}
}
$event->setQueryBuilder($queryBuilder);
}
/**
* Initialize the QueryBuilder object to generate reports from.
*/
public function onReportGraphGenerate(ReportGraphEvent $event): void
{
// Context check, we only want to fire for Lead reports
if (!$event->checkContext(self::CONTEXT_ASSET_DOWNLOAD)) {
return;
}
$graphs = $event->getRequestedGraphs();
$qb = $event->getQueryBuilder();
foreach ($graphs as $g) {
$options = $event->getOptions($g);
$queryBuilder = clone $qb;
$chartQuery = clone $options['chartQuery'];
$chartQuery->applyDateFilters($queryBuilder, 'date_download', 'ad');
switch ($g) {
case 'mautic.asset.graph.line.downloads':
$chart = new LineChart(null, $options['dateFrom'], $options['dateTo']);
$chartQuery->modifyTimeDataQuery($queryBuilder, 'date_download', 'ad');
$downloads = $chartQuery->loadAndBuildTimeData($queryBuilder);
$chart->setDataset($options['translator']->trans($g), $downloads);
$data = $chart->render();
$data['name'] = $g;
$event->setGraph($g, $data);
break;
case 'mautic.asset.table.most.downloaded':
$limit = 10;
$offset = 0;
$items = $this->downloadRepository->getMostDownloaded($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-download-line';
$graphData['link'] = 'mautic_asset_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.asset.table.top.referrers':
$limit = 10;
$offset = 0;
$items = $this->downloadRepository->getTopReferrers($queryBuilder, $limit, $offset);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-download-line';
$graphData['link'] = 'mautic_asset_action';
$event->setGraph($g, $graphData);
break;
case 'mautic.asset.graph.pie.statuses':
$items = $this->downloadRepository->getHttpStatuses($queryBuilder);
$graphData = [];
$graphData['data'] = $items;
$graphData['name'] = $g;
$graphData['iconClass'] = 'ri-earth-line';
$event->setGraph($g, $graphData);
break;
}
unset($queryBuilder);
}
}
public function onReportDisplay(ReportDataEvent $event): void
{
$data = $event->getData();
if ($event->checkContext([self::CONTEXT_ASSET_DOWNLOAD])) {
$data = $this->dncReportService->processDncStatusDisplay($data);
}
$event->setData($data);
unset($data);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\GlobalSearch;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private AssetModel $assetModel,
private CorePermissions $security,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
CoreEvents::BUILD_COMMAND_LIST => ['onBuildCommandList', 0],
];
}
public function onGlobalSearch(MauticEvents\GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->assetModel,
'@MauticAsset/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.asset.assets', $results);
}
}
public function onBuildCommandList(MauticEvents\CommandListEvent $event): void
{
if ($this->security->isGranted(['asset:assets:viewown', 'asset:assets:viewother'], 'MATCH_ONE')) {
$event->addCommands(
'mautic.asset.assets',
$this->assetModel->getCommandList()
);
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\AssetBundle\Entity\Download;
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
class StatsSubscriber extends CommonStatsSubscriber
{
public function __construct(CorePermissions $security, EntityManager $entityManager)
{
parent::__construct($security, $entityManager);
$this->addContactRestrictedRepositories([Download::class]);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Mautic\AssetBundle\EventListener;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Exception\FileInvalidException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\CoreBundle\Validator\FileUploadValidator;
use Oneup\UploaderBundle\Event\PostUploadEvent;
use Oneup\UploaderBundle\Event\ValidationEvent;
use Oneup\UploaderBundle\Uploader\Exception\ValidationException;
use Oneup\UploaderBundle\UploadEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class UploadSubscriber implements EventSubscriberInterface
{
public function __construct(
private CoreParametersHelper $coreParametersHelper,
private AssetModel $assetModel,
protected Translator $translator,
private FileUploadValidator $fileUploadValidator,
) {
}
public static function getSubscribedEvents(): array
{
return [
UploadEvents::POST_UPLOAD => ['onPostUpload', 0],
UploadEvents::VALIDATION => ['onUploadValidation', 0],
];
}
/**
* Moves upladed file to temporary directory where it can be found later
* and all uploaded files in there cleared. Also sets file name to the response.
*/
public function onPostUpload(PostUploadEvent $event): void
{
$request = $event->getRequest()->request;
$response = $event->getResponse();
$tempId = basename($request->get('tempId'));
$file = $event->getFile();
$config = $event->getConfig();
$uploadDir = $config['storage']['directory'];
$tmpDir = $uploadDir.'/tmp/'.$tempId;
// Move uploaded file to temporary folder
$file->move($tmpDir);
// Set resposnse data
$response['state'] = 1;
$response['tmpFileName'] = $file->getBasename();
}
/**
* Validates file before upload.
*
* @throws ValidationException
*/
public function onUploadValidation(ValidationEvent $event): void
{
$file = $event->getFile();
$extensions = $this->coreParametersHelper->get('allowed_extensions');
$configuredMimeTypes = $this->coreParametersHelper->get('allowed_mimetypes');
$allowedMimeTypes = array_intersect_key($configuredMimeTypes, array_flip($extensions));
$maxSize = $this->assetModel->getMaxUploadSize('B');
if (null === $file) {
return;
}
try {
$this->fileUploadValidator->checkFileSize($file->getSize(), $maxSize, 'mautic.asset.asset.error.file.size');
} catch (FileInvalidException $e) {
throw new ValidationException($e->getMessage());
}
try {
$this->fileUploadValidator->checkExtension($file->getExtension(), $extensions, 'mautic.asset.asset.error.file.extension');
} catch (FileInvalidException $e) {
throw new ValidationException($e->getMessage());
}
if (array_key_exists(strtolower($file->getExtension()), array_change_key_case($configuredMimeTypes, CASE_LOWER))) {
try {
$this->checkMimeType($file->getMimeType(), $allowedMimeTypes, 'mautic.asset.asset.error.file.mimetype');
} catch (FileInvalidException $e) {
throw new ValidationException($e->getMessage());
}
}
}
/**
* @param array<string,string> $allowedMimeTypes
*/
private function checkMimeType(string $mimeType, array $allowedMimeTypes, string $extensionErrorMsg): void
{
if (!in_array(strtolower($mimeType), array_map('strtolower', $allowedMimeTypes), true)) {
$error = $this->translator->trans($extensionErrorMsg, [
'%fileMimetype%' => $mimeType,
'%mimetypes%' => implode(', ', $allowedMimeTypes),
], 'validators');
throw new FileInvalidException($error);
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Mautic\AssetBundle\Form\Type;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class AssetListType extends AbstractType
{
public function __construct(
private CorePermissions $corePermissions,
private AssetModel $assetModel,
private UserHelper $userHelper,
) {
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => $this->getAssetChoices(),
'placeholder' => false,
'expanded' => false,
'multiple' => true,
'required' => false,
]);
}
public function getParent(): ?string
{
return ChoiceType::class;
}
private function getAssetChoices(): array
{
$choices = [];
$viewOther = $this->corePermissions->isGranted('asset:assets:viewother');
$repo = $this->assetModel->getRepository();
$repo->setCurrentUser($this->userHelper->getUser());
$assets = $repo->getAssetList('', 0, 0, $viewOther);
foreach ($assets as $asset) {
$choices[$asset['language']][$asset['title']] = $asset['id'];
}
// sort by language
ksort($choices);
return $choices;
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace Mautic\AssetBundle\Form\Type;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\ButtonGroupType;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\CoreBundle\Loader\ParameterLoader;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Url;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<Asset>
*/
class AssetType extends AbstractType
{
public function __construct(
private TranslatorInterface $translator,
private AssetModel $assetModel,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['description' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('asset.asset', $options));
$builder->add('storageLocation', ButtonGroupType::class, [
'label' => 'mautic.asset.asset.form.storageLocation',
'choices' => [
'mautic.asset.asset.form.storageLocation.local' => 'local',
'mautic.asset.asset.form.storageLocation.remote' => 'remote',
],
'attr' => [
'onchange' => 'Mautic.changeAssetStorageLocation();',
],
]);
$maxUploadSize = $this->assetModel->getMaxUploadSize('', true);
$builder->add(
'tempName',
HiddenType::class,
[
'label' => $this->translator->trans('mautic.asset.asset.form.file.upload', ['%max%' => $maxUploadSize]),
'label_attr' => ['class' => 'control-label'],
'required' => false,
'constraints' => [
new Callback([$this, 'validateExtension']),
],
]
);
$builder->add(
'originalFileName',
HiddenType::class,
[
'required' => false,
'constraints' => [
new Callback([$this, 'validateExtension']),
],
],
);
$builder->add(
'disallow',
YesNoButtonGroupType::class,
[
'label' => 'mautic.asset.asset.form.disallow.crawlers',
'attr' => [
'tooltip' => 'mautic.asset.asset.form.disallow.crawlers.descr',
'data-show-on' => '{"asset_storageLocation_0":"checked"}',
],
'data'=> empty($options['data']->getDisallow()) ? false : true,
]
);
$builder->add(
'remotePath',
TextType::class,
[
'label' => 'mautic.asset.asset.form.remotePath',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'required' => false,
'constraints' => [
new Url(
[
'message' => 'mautic.asset.validation.error.url',
]
),
],
]
);
$builder->add(
'title',
TextType::class,
[
'label' => 'mautic.core.title',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'alias',
TextType::class,
[
'label' => 'mautic.core.alias',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.asset.help.alias',
],
'required' => false,
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.core.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control editor'],
'required' => false,
]
);
$builder->add(
'category',
CategoryListType::class,
[
'bundle' => 'asset',
]
);
$builder->add('projects', ProjectType::class);
$builder->add('language', LocaleType::class, [
'label' => 'mautic.core.language',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.asset.form.language.help',
],
'required' => true,
'constraints' => [
new NotBlank(
[
'message' => 'mautic.core.value.required',
]
),
],
]);
$builder->add('isPublished', YesNoButtonGroupType::class, [
'label' => 'mautic.core.form.available',
]);
$builder->add('publishUp', PublishUpDateType::class);
$builder->add('publishDown', PublishDownDateType::class);
$builder->add(
'tempId',
HiddenType::class,
[
'required' => false,
]
);
$builder->add('buttons', FormButtonsType::class, []);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
/**
* @param Asset|string|null $object
*/
public function validateExtension($object, ExecutionContextInterface $context): void
{
if (empty($object)) {
return;
}
$parameters = (new ParameterLoader())->getParameterBag();
$extensions = $parameters->get('allowed_extensions');
$fileName = $object;
if (!is_string($object) && $object instanceof Asset) {
$fileName = $object->getOriginalFileName();
}
$fileExtension = pathinfo($fileName, PATHINFO_EXTENSION);
if (!in_array(strtolower($fileExtension), array_map('strtolower', $extensions), true)) {
$context->buildViolation('mautic.asset.asset.error.file.extension', [
'%fileExtension%'=> $fileExtension,
'%extensions%' => implode(', ', $extensions),
])
->atPath('file')
->setTranslationDomain('validators')
->addViolation();
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Asset::class]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Mautic\AssetBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class CampaignEventAssetDownloadType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'assets',
AssetListType::class,
[
'label' => 'mautic.asset.campaign.event.assets',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.campaign.event.assets.descr',
],
]
);
}
public function getBlockPrefix(): string
{
return 'campaignevent_assetdownload';
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Mautic\AssetBundle\Form\Type;
use Mautic\CoreBundle\Form\DataTransformer\ArrayStringTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<mixed>
*/
class ConfigType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'upload_dir',
TextType::class,
[
'label' => 'mautic.asset.config.form.upload.dir',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.config.form.upload.dir.tooltip',
],
'constraints' => [
new NotBlank([
'message' => 'mautic.core.value.required',
]),
],
]
);
$builder->add(
'max_size',
TextType::class,
[
'label' => 'mautic.asset.config.form.max.size',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.config.form.max.size.tooltip',
],
'constraints' => [
new NotBlank([
'message' => 'mautic.core.value.required',
]),
],
]
);
$arrayStringTransformer = new ArrayStringTransformer();
$builder->add(
$builder->create(
'allowed_extensions',
TextType::class,
[
'label' => 'mautic.asset.config.form.allowed.extensions',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.config.form.allowed.extensions.tooltip',
],
'required' => false,
]
)->addViewTransformer($arrayStringTransformer)
);
}
public function getBlockPrefix(): string
{
return 'assetconfig';
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Mautic\AssetBundle\Form\Type;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class FormSubmitActionDownloadFileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'asset',
AssetListType::class,
[
'expanded' => false,
'multiple' => false,
'label' => 'mautic.asset.form.submit.assets',
'label_attr' => ['class' => 'control-label'],
'placeholder' => 'mautic.asset.form.submit.latest.category',
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.form.submit.assets_descr',
],
]
);
$builder->add(
'category',
CategoryListType::class,
[
'label' => 'mautic.asset.form.submit.latest.category',
'label_attr' => ['class' => 'control-label'],
'placeholder' => false,
'required' => false,
'bundle' => 'asset',
'return_entity' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.form.submit.latest.category_descr',
'data-show-on' => '{"formaction_properties_asset":""}',
],
]
);
}
public function getBlockPrefix(): string
{
return 'asset_submitaction_downloadfile';
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Mautic\AssetBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class PointActionAssetDownloadType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'assets',
AssetListType::class,
[
'expanded' => false,
'multiple' => true,
'label' => 'mautic.asset.point.action.assets',
'label_attr' => ['class' => 'control-label'],
'placeholder' => false,
'required' => false,
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.asset.point.action.assets.descr',
],
]
);
}
public function getBlockPrefix(): string
{
return 'pointaction_assetdownload';
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Mautic\AssetBundle\Helper;
class PointActionHelper
{
public static function validateAssetDownload($eventDetails, $action): bool
{
$assetId = $eventDetails->getId();
$limitToAssets = $action['properties']['assets'];
if (!empty($limitToAssets) && !in_array($assetId, $limitToAssets)) {
// no points change
return false;
}
return true;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Mautic\AssetBundle\Helper;
use Mautic\AssetBundle\Model\AssetModel;
class TokenHelper
{
public const REGEX = '/{assetlink=(.*?)}/';
public function __construct(
protected AssetModel $model,
) {
}
public function findAssetTokens($content, $clickthrough = []): array
{
$tokens = [];
preg_match_all(self::REGEX, $content, $matches);
foreach ($matches[1] as $key => $assetId) {
$token = $matches[0][$key];
if (isset($tokens[$token])) {
continue;
}
$asset = $this->model->getEntity($assetId);
$tokens[$token] = (null !== $asset) ? $this->model->generateUrl($asset, true, $clickthrough) : '';
}
return $tokens;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\AssetBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticAssetBundle extends Bundle
{
}

View File

@@ -0,0 +1,623 @@
<?php
namespace Mautic\AssetBundle\Model;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\PersistentCollection;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Entity\Download;
use Mautic\AssetBundle\Event\AssetEvent;
use Mautic\AssetBundle\Event\AssetLoadEvent;
use Mautic\AssetBundle\Form\Type\AssetType;
use Mautic\CategoryBundle\Model\CategoryModel;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Helper\Chart\PieChart;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\FileHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Email;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\Factory\DeviceDetectorFactory\DeviceDetectorFactoryInterface;
use Mautic\LeadBundle\Tracker\Service\DeviceCreatorService\DeviceCreatorServiceInterface;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<Asset>
*/
class AssetModel extends FormModel implements GlobalSearchInterface
{
/**
* @var int
*/
protected $maxAssetSize;
public function __construct(
protected LeadModel $leadModel,
protected CategoryModel $categoryModel,
private RequestStack $requestStack,
protected IpLookupHelper $ipLookupHelper,
private DeviceCreatorServiceInterface $deviceCreatorService,
private DeviceDetectorFactoryInterface $deviceDetectorFactory,
private DeviceTrackingServiceInterface $deviceTrackingService,
private ContactTracker $contactTracker,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $logger,
CoreParametersHelper $coreParametersHelper,
) {
$this->maxAssetSize = $coreParametersHelper->get('max_size');
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $logger, $coreParametersHelper);
}
public function saveEntity($entity, $unlock = true): void
{
if (empty($this->inConversion)) {
$alias = $entity->getAlias();
if (empty($alias)) {
$alias = $entity->getTitle();
}
$alias = $this->cleanAlias($alias, '', 0, '-');
// make sure alias is not already taken
$repo = $this->getRepository();
$testAlias = $alias;
$count = $repo->checkUniqueAlias($testAlias, $entity);
$aliasTag = $count;
while ($count) {
$testAlias = $alias.$aliasTag;
$count = $repo->checkUniqueAlias($testAlias, $entity);
++$aliasTag;
}
if ($testAlias != $alias) {
$alias = $testAlias;
}
$entity->setAlias($alias);
}
if (!$entity->isNew()) {
// increase the revision
$revision = $entity->getRevision();
++$revision;
$entity->setRevision($revision);
}
parent::saveEntity($entity, $unlock);
}
/**
* @param array $systemEntry
*
* @throws \Doctrine\ORM\ORMException
* @throws \Exception
*/
public function trackDownload($asset, $request = null, int $code = 200, $systemEntry = []): void
{
// Don't skew results with in-house downloads
if (empty($systemEntry) && !$this->security->isAnonymous()) {
return;
}
if (null == $request) {
$request = $this->requestStack->getCurrentRequest();
}
if (!($request instanceof Request)) {
// likely this download came via a cron (no request), do not bother logging the download.
// https://github.com/mautic/mautic/issues/13577
return;
}
$download = new Download();
$download->setDateDownload(new \DateTime());
$download->setUtmCampaign($request->get('utm_campaign'));
$download->setUtmContent($request->get('utm_content'));
$download->setUtmMedium($request->get('utm_medium'));
$download->setUtmSource($request->get('utm_source'));
$download->setUtmTerm($request->get('utm_term'));
// Download triggered by lead
if (empty($systemEntry)) {
// check for any clickthrough info
$clickthrough = $request->get('ct', false);
if (!empty($clickthrough)) {
$clickthrough = $this->decodeArrayFromUrl($clickthrough);
if (!empty($clickthrough['lead'])) {
$lead = $this->leadModel->getEntity($clickthrough['lead']);
if (null !== $lead) {
$wasTrackedAlready = $this->deviceTrackingService->isTracked();
$deviceDetector = $this->deviceDetectorFactory->create($request->server->get('HTTP_USER_AGENT'));
$deviceDetector->parse();
$currentDevice = $this->deviceCreatorService->getCurrentFromDetector($deviceDetector, $lead);
$trackedDevice = $this->deviceTrackingService->trackCurrentDevice($currentDevice, false);
$trackingId = $trackedDevice->getTrackingId();
$trackingNewlyGenerated = !$wasTrackedAlready;
$leadClickthrough = true;
$this->contactTracker->setTrackedContact($lead);
}
}
if (!empty($clickthrough['channel'])) {
if (1 === count($clickthrough['channel'])) {
$channelId = reset($clickthrough['channel']);
$channel = key($clickthrough['channel']);
} else {
$channel = $clickthrough['channel'][0];
$channelId = (int) $clickthrough['channel'][1];
}
$download->setSource($channel);
$download->setSourceId($channelId);
} elseif (!empty($clickthrough['source'])) {
$download->setSource($clickthrough['source'][0]);
$download->setSourceId($clickthrough['source'][1]);
}
if (!empty($clickthrough['email'])) {
$emailRepo = $this->em->getRepository(Email::class);
if ($emailEntity = $emailRepo->getEntity($clickthrough['email'])) {
$download->setEmail($emailEntity);
}
}
}
if (empty($leadClickthrough)) {
$wasTrackedAlready = $this->deviceTrackingService->isTracked();
$lead = $this->contactTracker->getContact();
$trackedDevice = $this->deviceTrackingService->getTrackedDevice();
$trackingId = null;
$trackingNewlyGenerated = false;
if (null !== $trackedDevice) {
$trackingId = $trackedDevice->getTrackingId();
$trackingNewlyGenerated = !$wasTrackedAlready;
}
}
$download->setLead($lead);
} else {
$trackingId = '';
if (isset($systemEntry['lead'])) {
$lead = $systemEntry['lead'];
if (!$lead instanceof Lead) {
$leadId = is_array($lead) ? $lead['id'] : $lead;
$lead = $this->em->getReference(Lead::class, $leadId);
}
$download->setLead($lead);
}
if (!empty($systemEntry['source'])) {
$download->setSource($systemEntry['source'][0]);
$download->setSourceId($systemEntry['source'][1]);
}
if (isset($systemEntry['email'])) {
$email = $systemEntry['email'];
if (!$email instanceof Email) {
$emailId = is_array($email) ? $email['id'] : $email;
$email = $this->em->getReference(Email::class, $emailId);
}
$download->setEmail($email);
}
if (isset($systemEntry['tracking_id'])) {
$trackingId = $systemEntry['tracking_id'];
$trackingNewlyGenerated = false;
} elseif ($this->security->isAnonymous() && !defined('IN_MAUTIC_CONSOLE')) {
// If the session is anonymous and not triggered via CLI, assume the lead did something to trigger the
// system forced download such as an email
$deviceWasTracked = $this->deviceTrackingService->isTracked();
$deviceDetector = $this->deviceDetectorFactory->create($request->server->get('HTTP_USER_AGENT'));
$deviceDetector->parse();
$currentDevice = $this->deviceCreatorService->getCurrentFromDetector($deviceDetector, $lead);
$trackedDevice = $this->deviceTrackingService->trackCurrentDevice($currentDevice, false);
$trackingId = $trackedDevice->getTrackingId();
$trackingNewlyGenerated = !$deviceWasTracked;
}
}
$isUnique = true;
if (!empty($trackingNewlyGenerated)) {
// Cookie was just generated so this is definitely a unique download
$isUnique = $trackingNewlyGenerated;
} elseif (!empty($trackingId)) {
// Determine if this is a unique download
$isUnique = $this->getDownloadRepository()->isUniqueDownload($asset->getId(), $trackingId);
}
$download->setTrackingId($trackingId);
if (empty($systemEntry)) {
$download->setAsset($asset);
$this->getRepository()->upDownloadCount($asset->getId(), 1, $isUnique);
}
// check for existing IP
$ipAddress = $this->ipLookupHelper->getIpAddress();
$download->setCode($code);
$download->setIpAddress($ipAddress);
$download->setReferer($request->server->get('HTTP_REFERER'));
// Dispatch event
if ($this->dispatcher->hasListeners(AssetEvents::ASSET_ON_LOAD)) {
$event = new AssetLoadEvent($download, $isUnique);
$this->dispatcher->dispatch($event, AssetEvents::ASSET_ON_LOAD);
}
// Wrap in a try/catch to prevent deadlock errors on busy servers
try {
$this->em->persist($download);
$this->em->flush();
} catch (\Exception $e) {
if (MAUTIC_ENV === 'dev') {
throw $e;
} else {
error_log($e);
}
}
$this->em->detach($download);
}
/**
* Increase the download count.
*
* @param int $increaseBy
* @param bool|false $unique
*/
public function upDownloadCount($asset, $increaseBy = 1, $unique = false): void
{
$id = ($asset instanceof Asset) ? $asset->getId() : (int) $asset;
$this->getRepository()->upDownloadCount($id, $increaseBy, $unique);
}
/**
* @return \Mautic\AssetBundle\Entity\AssetRepository
*/
public function getRepository()
{
return $this->em->getRepository(Asset::class);
}
/**
* @return \Mautic\AssetBundle\Entity\DownloadRepository
*/
public function getDownloadRepository()
{
return $this->em->getRepository(Download::class);
}
public function getPermissionBase(): string
{
return 'asset:assets';
}
public function getNameGetter(): string
{
return 'getTitle';
}
/**
* @throws NotFoundHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Asset) {
throw new MethodNotAllowedHttpException(['Asset']);
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(AssetType::class, $entity, $options);
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?Asset
{
if (null === $id) {
$entity = new Asset();
} else {
$entity = parent::getEntity($id);
}
return $entity;
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof Asset) {
throw new MethodNotAllowedHttpException(['Asset']);
}
switch ($action) {
case 'pre_save':
$name = AssetEvents::ASSET_PRE_SAVE;
break;
case 'post_save':
$name = AssetEvents::ASSET_POST_SAVE;
break;
case 'pre_delete':
$name = AssetEvents::ASSET_PRE_DELETE;
break;
case 'post_delete':
$name = AssetEvents::ASSET_POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new AssetEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
/**
* Get list of entities for autopopulate fields.
*
* @return array
*/
public function getLookupResults($type, $filter = '', $limit = 10)
{
$results = [];
switch ($type) {
case 'asset':
$viewOther = $this->security->isGranted('asset:assets:viewother');
$request = $this->requestStack->getCurrentRequest();
$repo = $this->getRepository();
$repo->setCurrentUser($this->userHelper->getUser());
// During the form submit & edit, make sure that the data is checked against available assets
if ('mautic_segment_action' === $request->get('_route')
&& (Request::METHOD_POST === $request->getMethod() || 'edit' === $request->get('objectAction'))
) {
$limit = 0;
}
$results = $repo->getAssetList($filter, $limit, 0, $viewOther);
break;
case 'category':
$results = $this->categoryModel->getRepository()->getCategoryList($filter, $limit, 0);
break;
}
return $results;
}
/**
* Generate url for an asset.
*
* @param Asset $entity
* @param bool $absolute
* @param array $clickthrough
*
* @return string
*/
public function generateUrl($entity, $absolute = true, $clickthrough = [])
{
$assetSlug = $entity->getId().':'.$entity->getAlias();
$slugs = [
'slug' => $assetSlug,
];
return $this->buildUrl('mautic_asset_download', $slugs, $absolute, $clickthrough);
}
/**
* Determine the max upload size based on PHP restrictions and config.
*
* @param string $unit If '', determine the best unit based on the number
* @param bool|false $humanReadable Return as a human readable filesize
*
* @return float
*/
public function getMaxUploadSize($unit = 'M', $humanReadable = false)
{
$maxAssetSize = $this->maxAssetSize;
$maxAssetSize = (-1 == $maxAssetSize || 0 === $maxAssetSize) ? PHP_INT_MAX : FileHelper::convertMegabytesToBytes($maxAssetSize);
$maxPostSize = Asset::getIniValue('post_max_size');
$maxUploadSize = Asset::getIniValue('upload_max_filesize');
$memoryLimit = Asset::getIniValue('memory_limit');
$maxAllowed = min(array_filter([$maxAssetSize, $maxPostSize, $maxUploadSize, $memoryLimit]));
if ($humanReadable) {
$number = Asset::convertBytesToHumanReadable($maxAllowed);
} else {
[$number, $unit] = Asset::convertBytesToUnit($maxAllowed, $unit);
}
return $number;
}
/**
* @return int|string
*/
public function getTotalFilesize($assets)
{
$firstAsset = is_array($assets) ? reset($assets) : false;
if ($assets instanceof PersistentCollection || is_object($firstAsset)) {
$assetIds = [];
foreach ($assets as $asset) {
$assetIds[] = $asset->getId();
}
$assets = $assetIds;
}
if (!is_array($assets)) {
$assets = [$assets];
}
if (empty($assets)) {
return 0;
}
$repo = $this->getRepository();
$size = $repo->getAssetSize($assets);
if ($size) {
$size = Asset::convertBytesToHumanReadable($size);
}
return $size;
}
/**
* Get line chart data of downloads.
*
* @param string|null $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
* @param bool $canViewOthers
*/
public function getDownloadsLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
{
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$q = $query->prepareTimeDataQuery('asset_downloads', 'date_download', $filter);
if (!$canViewOthers) {
$q->join('t', MAUTIC_TABLE_PREFIX.'assets', 'a', 'a.id = t.asset_id')
->andWhere('a.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.asset.downloadcount'), $data);
return $chart->render();
}
/**
* Get pie chart data of unique vs repetitive downloads.
* Repetitive in this case mean if a lead downloaded any of the assets more than once.
*
* @param string $dateFrom
* @param string $dateTo
* @param array $filters
* @param bool $canViewOthers
*/
public function getUniqueVsRepetitivePieChartData($dateFrom, $dateTo, $filters = [], $canViewOthers = true): array
{
$chart = new PieChart();
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$allQ = $query->getCountQuery('asset_downloads', 'id', 'date_download', $filters);
$uniqueQ = $query->getCountQuery('asset_downloads', 'lead_id', 'date_download', $filters, ['getUnique' => true]);
if (!$canViewOthers) {
$allQ->join('t', MAUTIC_TABLE_PREFIX.'assets', 'a', 'a.id = t.asset_id')
->andWhere('a.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
$uniqueQ->join('t', MAUTIC_TABLE_PREFIX.'assets', 'a', 'a.id = t.asset_id')
->andWhere('a.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$all = $query->fetchCount($allQ);
$unique = $query->fetchCount($uniqueQ);
$repetitive = $all - $unique;
$chart->setDataset($this->translator->trans('mautic.asset.unique'), $unique);
$chart->setDataset($this->translator->trans('mautic.asset.repetitive'), $repetitive);
return $chart->render();
}
/**
* Get a list of popular (by downloads) assets.
*
* @param int $limit
* @param string $dateFrom
* @param string $dateTo
* @param array $filters
* @param bool $canViewOthers
*/
public function getPopularAssets($limit = 10, $dateFrom = null, $dateTo = null, $filters = [], $canViewOthers = true): array
{
$q = $this->em->getConnection()->createQueryBuilder();
$q->select('COUNT(DISTINCT t.id) AS download_count, a.id, a.title')
->from(MAUTIC_TABLE_PREFIX.'asset_downloads', 't')
->join('t', MAUTIC_TABLE_PREFIX.'assets', 'a', 'a.id = t.asset_id')
->orderBy('download_count', 'DESC')
->groupBy('a.id')
->setMaxResults($limit);
if (!$canViewOthers) {
$q->andWhere('a.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$chartQuery->applyFilters($q, $filters);
$chartQuery->applyDateFilters($q, 'date_download');
return $q->executeQuery()->fetchAllAssociative();
}
/**
* Get a list of assets in a date range.
*
* @param int $limit
* @param array $filters
* @param array $options
*/
public function getAssetList($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $options = []): array
{
$q = $this->em->getConnection()->createQueryBuilder();
$q->select('t.id, t.title as name, t.date_added, t.date_modified')
->from(MAUTIC_TABLE_PREFIX.'assets', 't')
->setMaxResults($limit);
if (!empty($options['canViewOthers'])) {
$q->andWhere('t.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
$chartQuery->applyFilters($q, $filters);
$chartQuery->applyDateFilters($q, 'date_added');
return $q->executeQuery()->fetchAllAssociative();
}
}

View File

@@ -0,0 +1,47 @@
{% extends '@MauticForm/Action/base_form_action.html.twig' %}
{% set footerContent %}
{% if action.properties.asset is defined %}
{% if action.properties.asset is null and action.properties.category is defined %}
<!-- Using last asset from category -->
{% set category = getEntity('Mautic\\CategoryBundle\\Entity\\Category', action.properties.category) %}
{% set categoryName = category ? category.title : '' %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [
{
label: 'mautic.form.field.asset.use_category'|trans({'%category_name%': categoryName}),
icon: 'ri-folder-line',
color: 'warm-gray',
attributes: {
href: path('mautic_asset_index'),
'target': '_blank'
}
}
]
} %}
{% elseif action.properties.asset is not null %}
<!-- Specific asset selected -->
{% set asset = getEntity('Mautic\\AssetBundle\\Entity\\Asset', action.properties.asset) %}
{% if asset %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [
{
label: asset.title,
icon: 'ri-file-line',
color: 'warm-gray',
attributes: securityIsGranted('asset:assets:viewother') ? {
href: path('mautic_asset_action', {'objectAction': 'view', 'objectId': asset.id}),
'target': '_blank'
} : {}
}
]
} %}
{% endif %}
{% endif %}
{% endif %}
{% endset %}
{% block action_label %}
{{ footerContent|raw }}
{% endblock %}

View File

@@ -0,0 +1,258 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block mauticContent %}asset{% endblock %}
{% block preHeader %}
{{- include('@MauticCore/Helper/page_actions.html.twig',
{
'item' : activeAsset,
'templateButtons' : {
'close' : securityHasEntityAccess(
permissions['asset:assets:viewown'],
permissions['asset:assets:viewother'],
activeAsset.getCreatedBy()
),
},
'routeBase' : 'asset',
'langVar' : 'asset.asset',
'nameGetter' : 'getTitle',
'targetLabel': 'mautic.asset.assets'|trans
}
) -}}
{{ include('@MauticCore/Modules/category--inline.html.twig', {'category': activeAsset.category}) }}
{{ include('@MauticProject/Modules/projects.html.twig', {'item': activeAsset}) }}
{% endblock %}
{% block headerTitle %} {{ activeAsset.getTitle() }} {% endblock %}
{% block actions %}
{{- include('@MauticCore/Helper/page_actions.html.twig',
{
'item' : activeAsset,
'templateButtons' : {
'edit' : securityHasEntityAccess(
permissions['asset:assets:editown'],
permissions['asset:assets:editother'],
activeAsset.getCreatedBy()
),
'clone' : permissions['asset:assets:create'],
'delete' : securityHasEntityAccess(
permissions['asset:assets:deleteown'],
permissions['asset:assets:deleteother'],
activeAsset.getCreatedBy()
),
},
'routeBase' : 'asset',
'langVar' : 'asset.asset',
'nameGetter' : 'getTitle',
}) -}}
{% endblock %}
{% block publishStatus %}
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': activeAsset,
'status': 'available'
}) -}}
<div class="label__divider"></div>
{# Asset type #}
{% if activeAsset.getFileType() is defined and activeAsset.getFileType() is not empty %}
{% set fileType = activeAsset.getFileType()|lower %}
{% set extensionGroups = activeAsset.getFileExtensions()|default([]) %}
{% set type = 'fallback' %}
{% for group, exts in extensionGroups %}
{% if fileType in exts %}
{% set type = group %}
{% endif %}
{% endfor %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: ('mautic.asset.type.' ~ type)|trans,
icon: activeAsset.getIconClass()|default(''),
color: 'high-contrast'
}]
} %}
{% endif %}
{# Disallow indexing #}
{% if activeAsset.getDisallow() is defined and activeAsset.getDisallow() == 1 %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: 'mautic.asset.tag.disallow.label'|trans,
icon: 'ri-eye-off-fill',
color: 'blue',
icon_only: true
}]
} %}
{% endif %}
{# Storage location #}
{% if activeAsset.getStorageLocation() is defined %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: ('mautic.asset.tag.storage.' ~ activeAsset.getStorageLocation())|trans,
icon: activeAsset.getStorageLocation() == 'local' ? 'ri-hard-drive-2-fill' : 'ri-cloud-fill',
color: 'blue',
icon_only: true
}]
} %}
{% endif %}
{# Language #}
{% if activeAsset.getLanguage() is defined and activeAsset.getLanguage() is not empty %}
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [{
label: activeAsset.getLanguage()|language_name|capitalize,
icon: 'ri-translate-2',
color: 'warm-gray',
attributes: {
'data-toggle': 'tooltip',
'data-placement': 'top',
'title': 'mautic.core.language'|trans
}
}]
} %}
{% endif %}
{% endblock %}
{% block content %}
<!-- start: box layout -->
<div class="box-layout">
<!-- left section -->
<div class="col-md-9 height-auto">
<div>
<!-- asset detail header -->
{% include '@MauticCore/Helper/description--expanded.html.twig' with {'description': activeAsset.description} %}
<!--/ asset detail header -->
<!-- asset detail collapseable -->
<div class="collapse pr-md pl-md" id="asset-details">
<div class="pr-md pl-md pb-md">
<div class="panel shd-none mb-0">
<table class="table table-hover mb-0">
<tbody>
{{- include(
'@MauticCore/Helper/details.html.twig',
{'entity' : activeAsset}
) -}}
<tr>
<td width="20%">
<span class="fw-b textTitle">
{% trans %}mautic.asset.asset.size{% endtrans %}
</span>
</td>
<td>{{ activeAsset.getSize() }}</td>
</tr>
<tr>
<td width="20%"><span class="fw-b textTitle">{% trans %}mautic.asset.asset.url{% endtrans %}</span></td>
<td>{{ assetDownloadUrl }}</td>
</tr>
<tr>
<td width="20%"><span class="fw-b textTitle">{% trans %}mautic.asset.filename.original{% endtrans %}</span></td>
<td>{{ activeAsset.getOriginalFilename() }}</td>
</tr>
<tr>
{% set location = activeAsset.getStorageLocation() %}
<td width="20%"><span class="fw-b textTitle">{{ ('mautic.asset.filename.' ~ location)|trans }}</span></td>
<td>{{ ('local' == location) ? activeAsset.getPath() : activeAsset.getRemotePath() }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!--/ asset detail collapseable -->
</div>
<div>
<!-- asset detail collapseable toggler -->
<div class="hr-expand nm">
<span data-toggle="tooltip" title="Detail">
<a href="javascript:void(0)" class="arrow text-secondary collapsed" data-toggle="collapse"
data-target="#asset-details"><span class="caret"></span> {% trans %}mautic.core.details{% endtrans %}</a>
</span>
</div>
<!--/ asset detail collapseable toggler -->
<!-- some stats -->
<div class="pa-md">
<div class="row">
<div class="col-sm-12">
{% include '@MauticCore/Modules/stat--icon.html.twig' with {
'stats': [
{
'title': 'mautic.asset.asset.downloads.total',
'value': stats.downloads.total,
'tooltip': 'mautic.asset.asset.downloads.total.all_time',
'icon': 'ri-download-line'
},
{
'title': 'mautic.asset.asset.downloads.unique',
'value': stats.downloads.unique,
'tooltip': 'mautic.asset.asset.downloads.unique.all_time',
'icon': 'ri-user-6-line'
}
]
} %}
<div class="panel">
<div class="panel-body box-layout">
<div class="col-md-4 va-m">
<h5 class="text-white dark-md fw-sb mb-xs">
<span class="ri-download-line"></span>
{% trans %}mautic.asset.graph.line.downloads{% endtrans %}
</h5>
</div>
<div class="col-md-8 va-m">
{{- include('@MauticCore/Helper/graph_dateselect.html.twig', {'dateRangeForm' : dateRangeForm, 'class' : 'pull-right'}) -}}
</div>
</div>
<div class="d-flex fd-column pt-0 pl-15 pb-15 pr-15 min-h-256">
{{- include('@MauticCore/Helper/chart.html.twig', {'chartData' : stats.downloads.timeStats, 'chartType' : 'line', 'chartHeight' : 300}) -}}
</div>
</div>
</div>
</div>
</div>
<!--/ stats -->
</div>
{{ customContent('details.stats.graph.below', _context) }}
</div>
<!--/ left section -->
<!-- right section -->
<div class="col-md-3 bdr-l height-auto">
<!-- preview URL -->
<div class="panel shd-none bdr-rds-0 bdr-w-0 mt-sm mb-0">
<div class="panel-body pt-xs">
{% include '@MauticCore/Components/card.html.twig' with {
type: 'link',
href: assetDownloadUrl,
ctaType: 'external',
heading: 'mautic.core.open_link',
attributes: {
'target': '_blank'
}
} %}
{{- include('@MauticAsset/Modules/preview.html.twig',
{
'variant': 'dialog',
'activeAsset' : activeAsset,
'assetDownloadUrl' : url(
'mautic_asset_action',
{'objectAction' : 'preview', 'objectId' : activeAsset.getId()}
)}) -}}
</div>
</div>
<!--/ preview URL -->
<hr class="hr-w-2" style="width:50%">
<!-- activity feed -->
{{- include('@MauticCore/Helper/recentactivity.html.twig', {'logs' : logs}) -}}
</div>
<!--/ right section -->
<input name="entityId" id="entityId" type="hidden" value="{{ activeAsset.getId() }}"/>
</div>
<!--/ end: box layout -->
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% set header = (activeAsset.getId()) ? 'mautic.asset.asset.menu.edit'|trans({'%name%' : activeAsset.getTitle()}) :
'mautic.asset.asset.menu.new'|trans %}
{% block headerTitle %}{{ header }}{% endblock %}
{% block mauticContent %}asset{% endblock %}
{% block content %}
<script>
mauticAssetUploadEndpoint = "{{ uploadEndpoint }}";
mauticAssetUploadMaxSize = {{ maxSize }};
mauticAssetUploadMaxSizeError = "{{ maxSizeError }}";
mauticAssetUploadExtensions = "{{ extensions }}";
mauticAssetUploadExtensionError = "{{ extensionError }}";
</script>
{{ form_start(form) }}
<!-- start: box layout -->
<div class="box-layout">
<!-- container -->
<div class="col-md-8 col-lg-9 height-auto bdr-r">
<div class="pa-md">
<div class="row">
<div class="col-lg-6">
<div class="col-md-7 pl-0">
{{ form_row(form.storageLocation) }}
</div>
<div class="text-left mt-lg mb-lg{% if startOnLocal %} hide {% endif %}" id="remote-button">
{% if integrations %}
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
href: path('mautic_asset_remote') ~ '?tmpl=modal',
icon: 'ri-file-search-line',
label: 'mautic.asset.remote.file.browse',
size: 'sm',
spin: true,
variant: 'tertiary',
attributes: {
'data-toggle': 'ajaxmodal',
'data-target': '#RemoteFileModal',
'data-header': 'mautic.asset.remote.file.browse'|trans,
'role': 'button'
}
}
]
} %}
{% endif %}
</div>
<div id="storage-local"{% if not startOnLocal %} class="hide"{% endif %}>
<div class="row">
<div class="form-group col-xs-12 ">
{{ form_label(form.tempName) }}
{{ form_widget(form.tempName) }}
{{ form_errors(form.tempName) }}
<div class="help-block mdropzone-error"></div>
<div class="mdropzone text-center" id="dropzone">
<div class="dz-message">
{% trans %}mautic.asset.drop.file.here{% endtrans %}</h4>
</div>
</div>
</div>
</div>
</div>
<div id="storage-remote"{% if startOnLocal %} class="hide"{% endif %}>
{{ form_row(form.remotePath) }}
</div>
<div>
{{ form_row(form.title) }}
</div>
<div>
{{ form_row(form.alias) }}
</div>
<div>
{{ form_row(form.description) }}
</div>
</div>
<div class="col-lg-6">
<div class="tile jc-center">
<div class="form-group col-xs-12 ">
{{- include('@MauticAsset/Modules/preview.html.twig', {
'variant': 'interactive',
'activeAsset' : activeAsset,
'assetDownloadUrl' : url('mautic_asset_action',
{'objectAction' : 'preview', 'objectId' : activeAsset.getId()}
)}) -}}
</div>
</div>
</div>
</div>
<div class="row">
</div>
<div class="row">
</div>
</div>
</div>
<div class="col-md-4 col-lg-3 height-auto">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.language) }}
{{ form_row(form.isPublished, {
'attr': {
'data-none': 'mautic.core.form.unavailable_regardless_of_scheduling',
'data-start': 'mautic.core.form.available_on_scheduled_date',
'data-both': 'mautic.core.form.available_during_scheduled_period',
'data-end': 'mautic.core.form.available_until_scheduled_end'
}
}) }}
{{ form_row(form.publishUp, {'label': 'mautic.core.form.available.available_from'}) }}
{{ form_row(form.publishDown, {'label': 'mautic.core.form.available.unavailable_from'}) }}
{{ form_row(form.disallow) }}
</div>
</div>
</div>
{{ form_end(form) }}
{% if integrations %}
{{- include('@MauticCore/Helper/modal.html.twig', {
'id' : 'RemoteFileModal',
'size' : 'lg',
'footerButtons' : true,
}) -}}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,292 @@
{% set isIndex = tmpl == 'index' ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}asset
{% endblock %}
{% block headerTitle %}
{% trans %}mautic.asset.assets{% endtrans %}
{% endblock %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %}panel-default">
{{- include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['asset:assets:create'],
},
'routeBase': 'asset',
'langVar': 'asset.asset',
},
'bulk_actions': {
'langVar': 'asset.asset',
'routeBase': 'asset',
'templateButtons': {
'delete': permissions['asset:assets:deleteown'] or permissions['asset:assets:deleteother'],
},
},
'quickFilters': [
{
'search': 'mautic.core.searchcommand.isuncategorized',
'label': 'mautic.core.form.uncategorized',
'tooltip': 'mautic.core.search.quickfilter.is_uncategorized',
'icon': 'ri-folder-unknow-line'
},
{
'search': 'mautic.core.searchcommand.ispublished',
'label': 'mautic.core.form.available',
'tooltip': 'mautic.core.search.quickfilter.is_published',
'icon': 'ri-check-line'
},
{
'search': 'mautic.core.searchcommand.isunpublished',
'label': 'mautic.core.form.unavailable',
'tooltip': 'mautic.core.search.quickfilter.is_unpublished',
'icon': 'ri-close-line'
},
{
'search': 'mautic.core.searchcommand.ismine',
'label': 'mautic.core.searchcommand.ismine.label',
'tooltip': 'mautic.core.searchcommand.ismine.description',
'icon': 'ri-user-line'
},
{
'search': 'mautic.asset.asset.searchcommand.isexpired',
'label': 'mautic.core.form.no_longer_available',
'tooltip': 'mautic.asset.asset.searchcommand.isexpired.description',
'icon': 'ri-time-line'
},
{
'search': 'mautic.asset.asset.searchcommand.ispending',
'label': 'mautic.core.form.not_yet_available',
'tooltip': 'mautic.asset.asset.searchcommand.ispending.description',
'icon': 'ri-timer-line'
}
]
}) -}}
<div class="page-list">
{{ block('listResults') }}
</div>
</div>
{% else %}
{{ block('listResults') }}
{% endif %}
{% endblock %}
{% block listResults %}
{% if items|length %}
<div class="table-responsive">
<table class="table table-hover asset-list" id="assetTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#assetTable',
}) }}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'a.title',
'text' : 'mautic.core.title',
'class' : 'col-asset-title',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'c.title',
'text' : 'mautic.core.category',
'class' : 'visible-md visible-lg col-asset-category',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'a.downloadCount',
'text' : 'mautic.asset.asset.thead.download.count',
'class' : 'visible-md visible-lg col-asset-download-count',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'a.dateAdded',
'text' : 'mautic.lead.import.label.dateAdded',
'class' : 'visible-md visible-lg col-asset-dateAdded',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'a.dateModified',
'text' : 'mautic.lead.import.label.dateModified',
'class' : 'visible-md visible-lg col-asset-dateModified',
'default' : true,
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'a.createdByUser',
'text' : 'mautic.core.createdby',
'class' : 'visible-md visible-lg col-asset-createdByUser',
}
) -}}
{{- include(
'@MauticCore/Helper/tableheader.html.twig',
{
'sessionVar' : 'asset',
'orderBy' : 'a.id',
'text' : 'mautic.core.id',
'class' : 'visible-md visible-lg col-asset-id',
}
) -}}
</tr>
</thead>
<tbody>
{% for k, item in items %}
<tr>
<td>
{{- include(
'@MauticCore/Helper/list_actions.html.twig',
{
'item' : item,
'templateButtons' : {
'edit' : securityHasEntityAccess(
permissions['asset:assets:editown'],
permissions['asset:assets:editother'],
item.getCreatedBy()
),
'delete' : securityHasEntityAccess(
permissions['asset:assets:deleteown'],
permissions['asset:assets:deleteother'],
item.getCreatedBy()
),
'clone' : permissions['asset:assets:create'],
},
'routeBase' : 'asset',
'langVar' : 'asset.asset',
'nameGetter' : 'getTitle',
'customButtons' : {
0: {
'attr' : {
'data-toggle' : 'ajaxmodal',
'data-target' : '#AssetPreviewModal',
'href' : path(
'mautic_asset_action',
{'objectAction' : 'preview', 'objectId' : item.getId(), 'stream': 0}
),
},
'btnText' : 'mautic.asset.asset.preview'|trans,
'iconClass' : 'ri-image-circle-line',
},
1: {
'attr' : {
'data-copy' : url('mautic_asset_download', {'slug': item.getId() ~ ':' ~ item.getAlias()}),
'data-toggle' : 'none',
},
'btnText' : 'mautic.core.copy_download_link'|trans,
'iconClass' : 'ri-clipboard-line',
},
},
}
) -}}
</td>
<td>
<div>
{{- include(
'@MauticCore/Helper/publishstatus_icon.html.twig',
{
'item' : item,
'model' : 'asset.asset',
}
) -}}
<a href="{{ path(
'mautic_asset_action',
{'objectAction' : 'view', 'objectId' : item.getId()}
) }}"
data-toggle="ajax">
{{ item.getTitle() }} ({{ item.getAlias() }})
</a>
<i class="{{ item.getIconClass() }}"></i>
{{ customContent('asset.name', _context) }}
{{ include('@MauticProject/Modules/projects.html.twig') }}
</div>
{% set description = item.getDescription() %}
{% if description %}
{{ include('@MauticCore/Helper/description--inline.html.twig', {
'description': description
}) }}
{% endif %}
</td>
<td class="visible-md visible-lg">
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.getCategory()}) }}
</td>
<td class="visible-md visible-lg">{{ item.getDownloadCount() }}</td>
<td class="visible-md visible-lg" title="{{ item.getDateAdded() ? dateToFullConcat(item.getDateAdded()) : '' }}">
{{ item.getDateAdded() ? dateToDate(item.getDateAdded()) : '' }}
</td>
<td class="visible-md visible-lg" title="{{ item.getDateModified() ? dateToFullConcat(item.getDateModified()) : '' }}">
{{ item.getDateModified() ? dateToDate(item.getDateModified()) : '' }}
</td>
<td class="visible-md visible-lg">{{ item.getCreatedByUser() }}</td>
<td class="visible-md visible-lg">{{ item.getId() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel-footer">
{{- include('@MauticCore/Helper/pagination.html.twig', {
'totalItems' : items|length,
'page' : page,
'limit' : limit,
'menuLinkId' : 'mautic_asset_index',
'baseUrl' : path('mautic_asset_index'),
'sessionVar' : 'asset',
}) -}}
</div>
{% else %}
{% if searchValue is not empty %}
{{- include('@MauticCore/Helper/noresults.html.twig', {'tip' : 'mautic.asset.noresults.tip'}) -}}
{% else %}
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
{% set childContainer %}
<div class="mb-md">
{% include '@MauticCore/Components/pictogram.html.twig' with {
'pictogram': 'cloud--assets',
'size': '80'
} %}
</div>
{% endset %}
{{ include('@MauticCore/Components/content-block.html.twig', {
heading: 'mautic.asset.onboarding.heading',
subheading: 'mautic.asset.onboarding.subheading',
copy: 'mautic.asset.onboarding.copy',
childContainer: childContainer,
}) }}
</div>
{% endif %}
{% endif %}
{{- include('@MauticCore/Helper/modal.html.twig', {
'id' : 'AssetPreviewModal',
'header' : false,
}) -}}
{{ include('@MauticCore/Modules/protip.html.twig', {
tip: random(['mautic.protip.assets.gating', 'mautic.protip.assets.naming', 'mautic.protip.assets.repurpose', 'mautic.protip.assets.track'])
}) }}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% block _config_assetconfig_widget %}
<h4 class="fw-sb mt-48 mb-xs">{% trans %}mautic.config.tab.assetconfig{% endtrans %}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.assetconfig.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
{% for f in form.children %}
<div class="col-xs-12">
{{ form_row(f) }}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,113 @@
{% set variant = variant|default('') %}
{% set isPreviewable = activeAsset.isImage() or 'pdf' == activeAsset.getFileType()|lower or activeAsset.getMime() starts with 'video' or activeAsset.getMime() starts with 'audio' %}
{% macro renderPreviewContent(activeAsset, assetDownloadUrl) %}
{% if activeAsset.isImage() %}
<img src="{{ assetDownloadUrl ~ '?stream=1' }}" alt="{{ activeAsset.getTitle()|escape }}" class="img-thumbnail" />
{% elseif 'pdf' == activeAsset.getFileType()|lower %}
<iframe src="{{ assetDownloadUrl ~ '?stream=1#view=FitH' }}" style="width: 100%; height: 70vh; border: none;" title="{{ 'mautic.asset.preview.pdf_iframe_title'|trans({'%title%': activeAsset.getTitle()|escape}) }}"></iframe>
{% elseif activeAsset.getMime() starts with 'video' or activeAsset.getExtension() in ['mpg', 'mpeg', 'mp4', 'webm'] %}
<video src="{{ assetDownloadUrl ~ '?stream=1' }}" controls style="width: 100%;">
{{ 'mautic.asset.no_video_support'|trans }}
</video>
{% elseif activeAsset.getMime() starts with 'audio' or activeAsset.getExtension() in ['mp3', 'ogg', 'wav'] %}
<audio controls>
<source src="{{ assetDownloadUrl ~ '?stream=1' }}" type="{{ activeAsset.getMime() }}">
{{ 'mautic.asset.no_audio_support'|trans }}
</audio>
{% else %}
<div class="d-flex jc-center ai-center text-helper">
<i class="{{ activeAsset.getIconClass() }} ri-lg mr-xs"></i>
<span>{{ 'mautic.asset.no_preview'|trans }}</span>
</div>
{% endif %}
{% endmacro %}
{% if variant == 'interactive' %}
{% if isPreviewable %}
<h5 class="fw-sb mb-xs">{{ 'mautic.asset.asset.preview'|trans }}</h5>
<div class="asset-preview">
{% if activeAsset.isImage() %}
<div class="asset-preview--image">
<div class="asset-preview__label">
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [
{
label: 'mautic.asset.click_to_zoom',
color: 'gray',
size: 'sm'
}
]
} %}
</div>
{{ _self.renderPreviewContent(activeAsset, assetDownloadUrl) }}
</div>
{% elseif 'pdf' == activeAsset.getFileType()|lower %}
<div class="asset-preview__label">
{% include '@MauticCore/Helper/_tag.html.twig' with {
tags: [
{
label: 'mautic.asset.click_to_view_full_size',
color: 'gray',
size: 'sm',
attributes: {
'href': '#',
'data-toggle': 'modal',
'data-target': '#pdf-preview-modal'
}
}
]
} %}
</div>
<div class="pdf-preview-thumbnail">
{{ _self.renderPreviewContent(activeAsset, assetDownloadUrl) }}
</div>
{% include '@MauticCore/Components/modal.html.twig' with {
id: 'pdf-preview-modal',
size: 'xl',
type: 'productive',
modalHeading: activeAsset.getTitle()|escape,
modalAriaLabel: 'PDF Preview',
modalContent: _self.renderPreviewContent(activeAsset, assetDownloadUrl),
hasScrollingContent: false
} %}
{% else %}
{{ _self.renderPreviewContent(activeAsset, assetDownloadUrl) }}
{% endif %}
</div>
{% else %}
<div class="d-flex jc-center ai-center text-helper">
<i class="{{ activeAsset.getIconClass() }} ri-lg mr-xs"></i>
<span>{{ 'mautic.asset.no_preview'|trans }}</span>
</div>
{% endif %}
<div class="clearfix"></div>
{% elseif variant == 'dialog' %}
{% set modalId = 'asset-dialog-preview-modal-' ~ activeAsset.id|default('new') %}
{% if isPreviewable %}
{% include '@MauticCore/Components/tile.html.twig' with {
type: 'mini',
href: '#',
title: 'mautic.asset.open_preview',
icon: 'ri-rectangle-line',
attributes: {
'data-toggle': 'modal',
'data-target': '#' ~ modalId
}
} %}
{% endif %}
{% include '@MauticCore/Components/modal.html.twig' with {
id: modalId,
size: 'xl',
type: 'productive',
modalHeading: activeAsset.getTitle()|escape,
modalAriaLabel: 'mautic.asset.preview.ariaLabel'|trans({'%title%': activeAsset.getTitle()|escape}),
modalContent: _self.renderPreviewContent(activeAsset, assetDownloadUrl)
} %}
{% else %}
{{ _self.renderPreviewContent(activeAsset, assetDownloadUrl) }}
{% endif %}

View File

@@ -0,0 +1,54 @@
{% set isIndex = tmpl == 'index' ? true : false %}
{% set tmpl = 'list' %}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent %}asset{% endblock %}
{% block headerTitle %}{% trans %}mautic.asset.remote.file.browse{% endtrans %}{% endblock %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="panel panel-default">
<div class="page-list">
{{ block('mainContent') }}
</div>
</div>
{% else %}
{{ block('mainContent') }}
{% endif %}
{% endblock %}
{% block mainContent %}
{% if integrations|length %}
<!-- start: box layout -->
<div class="box-layout">
<!-- step container -->
<div class="col-md-3">
<div class="pt-md pr-md pb-md">
<ul class="list-group list-group-tabs">
{% for integration in integrations %}
<li class="list-group-item{% if loop.index0 is same as(0) %} active{% endif %}" id="tab{{ integration.getName() }}">
<a href="javascript: void(0);" class="list-group-item-heading steps" onclick="Mautic.updateRemoteBrowser('{{ integration.getName() }}');">
{{ integration.getDisplayName() }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<!--/ step container -->
<!-- container -->
<div class="col-md-9 bdr-l">
<div id="remoteFileBrowser">
<div class="alert alert-warning col-md-6 col-md-offset-3 mt-md">
<p>{% trans %}mautic.asset.remote.select_service{% endtrans %}</p>
</div>
</div>
</div>
<!--/ end: container -->
</div>
<!--/ end: box layout -->
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% if items|length %}
<div class="panel panel-primary mb-0">
<div class="panel-body">
<input type='text' class='remote-file-search form-control mb-lg' autocomplete='off' placeholder="{% trans %}mautic.core.search.placeholder{% endtrans %}" />
<div class="list-group remote-file-list">
{% if items.dirs is defined %}
{% for item in items.dirs %}
<a class="list-group-item" href="javascript: void(0);" onclick="Mautic.updateRemoteBrowser('{{ integration.getName() }}', '/{{ item|trim('/','right') }}');">
{{ item }}
</a>
{% endfor %}
{% for item in items.keys %}
<a class="list-group-item" href="javascript: void(0);" onclick="Mautic.selectRemoteFile('{{ integration.getPublicUrl(item) }}');">
{{ item }}
</a>
{% endfor %}
{% else %}
{% for item in items %}
{% if connector.getAdapter().isDirectory(item) %}
<a class="list-group-item" href="javascript: void(0);" onclick="Mautic.updateRemoteBrowser('{{ integration.getName() }}', '/{{ item|trim('/', 'right') }}');">
{{ item }}
</a>
{% else %}
<a class="list-group-item" href="javascript: void(0);" onclick="Mautic.selectRemoteFile('{{ integration.getPublicUrl(item) }}');">
{{ item }}
</a>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% else %}
{{- include('@MauticCore/Helper/noresults.html.twig', {'message' : 'mautic.asset.remote.no_results'}) -}}
{% endif %}

View File

@@ -0,0 +1,21 @@
{% if showMore is defined and showMore is not empty %}
<a href="{{ url('mautic_asset_index', {'search' : searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%' : remaining})|escape }}</span>
</a>
{% else %}
<a class="gsearch--results-common" href="{{ url('mautic_asset_action', {'objectAction' : 'view', 'objectId' : item.getId()}) }}" data-toggle="ajax">
<i class="{{ item.getIconClass() }} gsearch--results-common__icon mr-3"></i>
<span class="fw-sb">{{ item.getTitle()|escape }}</span>
<span class="ml-4 mr-4">#{{ item.getId() }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': 'available',
'simplified': 'true'
}) -}}
<span size="sm" class="pull-right" data-toggle="tooltip" title="{% trans %}mautic.asset.downloadcount{% endtrans %}" data-placement="left">
<i class="ri-download-line"></i>
{{ item.getDownloadCount() }}
</span>
</a>
{% endif %}

View File

@@ -0,0 +1,7 @@
<div>
Hello
{{- include('@MauticAsset/Asset/preview.html.twig', {'activeAsset' : event.extra.asset, 'assetDownloadUrl' : url(
'mautic_asset_action',
{'objectAction' : 'preview', 'objectId' : event.extra.asset.getId()}
)}) -}}
</div>

View File

@@ -0,0 +1,32 @@
<?php
namespace Mautic\AssetBundle\Security\Permissions;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Symfony\Component\Form\FormBuilderInterface;
class AssetPermissions extends AbstractPermissions
{
public function __construct(CoreParametersHelper $coreParametersHelper)
{
parent::__construct($coreParametersHelper->all());
}
public function definePermissions(): void
{
$this->addExtendedPermissions('assets');
$this->addStandardPermissions('categories');
}
public function getName(): string
{
return 'asset';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields('asset', 'categories', $builder, $data);
$this->addExtendedFormFields('asset', 'assets', $builder, $data);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\Asset;
use Doctrine\ORM\ORMException;
use Doctrine\Persistence\Mapping\MappingException;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\CoreBundle\Helper\CsvHelper;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
abstract class AbstractAssetTestCase extends MauticMysqlTestCase
{
protected Asset $asset;
protected string $expectedMimeType;
protected string $expectedContentDisposition;
protected string $expectedPngContent;
protected string $csvPath;
protected function setUp(): void
{
parent::setUp();
$this->generateCsv();
$assetData = [
'title' => 'Asset controller test. Preview action',
'alias' => 'Test',
'createdAt' => new \DateTime('2021-05-05 22:30:00'),
'updatedAt' => new \DateTime('2022-05-05 22:30:00'),
'createdBy' => 'User',
'storage' => 'local',
'path' => basename($this->csvPath),
'extension' => 'png',
];
$this->asset = $this->createAsset($assetData);
$this->expectedMimeType = 'text/plain; charset=UTF-8';
$this->expectedContentDisposition = 'attachment;filename="';
$this->expectedPngContent = file_get_contents($this->csvPath);
}
protected function beforeTearDown(): void
{
if (file_exists($this->csvPath)) {
unlink($this->csvPath);
}
}
/**
* Create an asset entity in the DB.
*
* @param array<string, string|mixed> $assetData
*
* @throws ORMException
* @throws MappingException
*/
protected function createAsset(array $assetData): Asset
{
$asset = new Asset();
$asset->setTitle($assetData['title']);
$asset->setAlias($assetData['alias']);
$asset->setDateAdded($assetData['createdAt'] ?? new \DateTime());
$asset->setDateModified($assetData['updatedAt'] ?? new \DateTime());
$asset->setCreatedByUser($assetData['createdBy'] ?? 'User');
$asset->setStorageLocation($assetData['storage'] ?? 'local');
$asset->setPath($assetData['path'] ?? '');
$asset->setExtension($assetData['extension'] ?? '');
$asset->setSize($this->csvPath ? filesize($this->csvPath) : 0);
$this->em->persist($asset);
$this->em->flush();
$this->em->detach($asset);
return $asset;
}
/**
* Generate the csv asset and return the path of the asset.
*/
protected function generateCsv(): void
{
$uploadDir = static::getContainer()->get('mautic.helper.core_parameters')->get('upload_dir') ?? sys_get_temp_dir();
$tmpFile = tempnam($uploadDir, 'mautic_asset_test_');
$file = fopen($tmpFile, 'w');
$initialList = [
['email', 'firstname', 'lastname'],
['john.doe@his-site.com.email', 'John', 'Doe'],
['john.smith@his-site.com.email', 'John', 'Smith'],
['jim.doe@his-site.com.email', 'Jim', 'Doe'],
[''],
['jim.smith@his-site.com.email', 'Jim', 'Smith'],
];
foreach ($initialList as $line) {
CsvHelper::putCsv($file, $line);
}
fclose($file);
$this->csvPath = $tmpFile;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\AssetBundle\Tests\Controller\Api;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
class AssetApiControllerFunctionalTest extends MauticMysqlTestCase
{
public function testCreateNewRemoteAsset(): void
{
$payload = [
'file' => 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
'storageLocation' => 'remote',
'title' => 'title',
];
$this->client->request('POST', 'api/assets/new', $payload);
$clientResponse = $this->client->getResponse();
$this->assertResponseStatusCodeSame(201, $clientResponse->getContent());
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals($payload['title'], $response['asset']['title']);
$this->assertEquals($payload['storageLocation'], $response['asset']['storageLocation']);
$this->assertStringContainsString('application/pdf', $response['asset']['mime']);
$this->assertStringContainsString('pdf', $response['asset']['extension']);
$this->assertNotNull($response['asset']['size']);
}
public function testCreateNewRemoteAssetWithVulnerableFile(): void
{
$payload = [
'file' => 'file:///etc/passwd',
'storageLocation' => 'remote',
'title' => 'title',
];
$this->client->request('POST', 'api/assets/new', $payload);
$clientResponse = $this->client->getResponse();
$this->assertResponseStatusCodeSame(400, $clientResponse->getContent());
$this->assertEquals('{"errors":[{"code":400,"message":"remotePath: The remote should be a valid URL.","details":{"remotePath":["The remote should be a valid URL."]}}]}', $clientResponse->getContent());
}
public function testCreateNewLocalAsset(): void
{
$assetsPath = $this->client->getKernel()->getContainer()->getParameter('mautic.upload_dir');
file_put_contents($assetsPath.'/file.txt', 'test');
$payload = [
'file' => 'file.txt',
'storageLocation' => 'local',
'title' => 'title',
];
$this->client->request('POST', 'api/assets/new', $payload);
$clientResponse = $this->client->getResponse();
$this->assertResponseStatusCodeSame(201, $clientResponse->getContent());
$response = json_decode($clientResponse->getContent(), true);
$this->assertEquals($payload['title'], $response['asset']['title']);
$this->assertEquals($payload['storageLocation'], $response['asset']['storageLocation']);
$this->assertStringContainsString('text/plain', $response['asset']['mime']);
$this->assertNotNull($response['asset']['size']);
$this->assertStringContainsString('txt', $response['asset']['extension']);
unlink($assetsPath.'/file.txt');
}
}

View File

@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\Controller;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Tests\Asset\AbstractAssetTestCase;
use Mautic\CoreBundle\Tests\Traits\ControllerTrait;
use Mautic\PageBundle\Tests\Controller\PageControllerTest;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\UserBundle\Entity\Permission;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\RoleModel;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class AssetControllerFunctionalTest extends AbstractAssetTestCase
{
use ControllerTrait;
private const SALES_USER = 'sales';
private const ADMIN_USER = 'admin';
/**
* Index action should return status code 200.
*/
public function testIndexAction(): void
{
$asset = new Asset();
$asset->setTitle('test');
$asset->setAlias('test');
$asset->setDateAdded(new \DateTime('2020-02-07 20:29:02'));
$asset->setDateModified(new \DateTime('2020-03-21 20:29:02'));
$asset->setCreatedByUser('Test User');
$this->em->persist($asset);
$this->em->flush();
$this->em->detach($asset);
$urlAlias = 'assets';
$routeAlias = 'asset';
$column = 'dateModified';
$column2 = 'title';
$tableAlias = 'a.';
$this->getControllerColumnTests($urlAlias, $routeAlias, $column, $tableAlias, $column2);
}
public function testAssetSizes(): void
{
$this->client->request('GET', '/s/ajax?action=email:getAttachmentsSize&assets%5B%5D='.$this->asset->getId());
$this->assertResponseIsSuccessful();
Assert::assertSame('{"size":"178 bytes"}', $this->client->getResponse()->getContent());
}
/**
* Preview action should return the file content.
*/
public function testPreviewActionStreamByDefault(): void
{
$this->client->request('GET', '/s/assets/preview/'.$this->asset->getId());
ob_start();
$response = $this->client->getResponse();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
$this->assertSame($this->expectedMimeType, $response->headers->get('Content-Type'));
$this->assertNotSame($this->expectedContentDisposition.$this->asset->getOriginalFileName(), $response->headers->get('Content-Disposition'));
$this->assertEquals($this->expectedPngContent, $content);
}
/**
* Preview action should return the file content.
*/
public function testPreviewActionStreamIsZero(): void
{
$this->client->request('GET', '/s/assets/preview/'.$this->asset->getId().'?stream=0&download=1');
ob_start();
$response = $this->client->getResponse();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
$this->assertSame($this->expectedContentDisposition.$this->asset->getOriginalFileName(), $response->headers->get('Content-Disposition'));
$this->assertEquals($this->expectedPngContent, $content);
}
/**
* Preview action should return the html code.
*/
public function testPreviewActionStreamDownloadAreZero(): void
{
$this->client->request('GET', '/s/assets/preview/'.$this->asset->getId().'?stream=0&download=0');
ob_start();
$response = $this->client->getResponse();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
$this->assertSame(Response::HTTP_OK, $response->getStatusCode(), $content);
$this->assertNotEquals($this->expectedPngContent, $content);
PageControllerTest::assertTrue($response->isOk());
$assetSlug = $this->asset->getId().':'.$this->asset->getAlias();
PageControllerTest::assertStringContainsString(
'/asset/'.$assetSlug,
$content,
'The return must contain the assert slug'
);
}
/**
* @param array<string, string[]> $permission
*/
#[\PHPUnit\Framework\Attributes\DataProvider('getValuesProvider')]
public function testEditWithPermissions(string $route, array $permission, int $expectedStatusCode, string $userCreatorUN): void
{
$userCreator = $this->getUser($userCreatorUN);
$userEditor = $this->getUser(self::SALES_USER);
$this->setPermission($userEditor, ['asset:assets' => $permission]);
$asset = new Asset();
$asset->setTitle('Asset A');
$asset->setAlias('asset-a');
$asset->setStorageLocation('local');
$asset->setPath('broken-image.jpg');
$asset->setExtension('jpg');
$asset->setCreatedByUser($userCreator->getUserIdentifier());
$asset->setCreatedBy($userCreator->getId());
$this->em->persist($asset);
$this->em->flush();
$this->em->clear();
$this->logoutUser();
$this->loginUser($userEditor);
$this->client->request(Request::METHOD_GET, "/s/assets/{$route}/{$asset->getId()}");
Assert::assertSame($expectedStatusCode, $this->client->getResponse()->getStatusCode());
}
/**
* @return \Generator<string, mixed[]>
*/
public static function getValuesProvider(): \Generator
{
yield 'The sales user with edit own permission can edits its own asset' => [
'route' => 'edit',
'permission' => ['editown'],
'expectedStatusCode' => Response::HTTP_OK,
'userCreatorUN' => self::SALES_USER,
];
yield 'The sales user with edit own permission cannot edit asset created by admin' => [
'route' => 'edit',
'permission' => ['editown'],
'expectedStatusCode' => Response::HTTP_FORBIDDEN,
'userCreatorUN' => self::ADMIN_USER,
];
yield 'The sales user with edit other permission can edit asset created by admin' => [
'route' => 'edit',
'permission' => ['editown', 'editother'],
'expectedStatusCode' => Response::HTTP_OK,
'userCreatorUN' => self::ADMIN_USER,
];
yield 'The sales user with view own permission cannot edit or asset created by admin' => [
'route' => 'edit',
'permission' => ['viewown'],
'expectedStatusCode' => Response::HTTP_FORBIDDEN,
'userCreatorUN' => self::ADMIN_USER,
];
yield 'The sales user with view other permission cannot edit asset created by admin' => [
'route' => 'edit',
'permission' => ['viewown', 'viewother'],
'expectedStatusCode' => Response::HTTP_FORBIDDEN,
'userCreatorUN' => self::ADMIN_USER,
];
yield 'The sales user with view own permission cannot view asset created by admin' => [
'route' => 'view',
'permission' => ['viewown'],
'expectedStatusCode' => Response::HTTP_FORBIDDEN,
'userCreatorUN' => self::ADMIN_USER,
];
yield 'The sales user with view others permission can view asset created by admin' => [
'route' => 'view',
'permission' => ['viewown', 'viewother'],
'expectedStatusCode' => Response::HTTP_OK,
'userCreatorUN' => self::ADMIN_USER,
];
yield 'The sales user with view own permission can view its own asset' => [
'route' => 'view',
'permission' => ['viewown'],
'expectedStatusCode' => Response::HTTP_OK,
'userCreatorUN' => self::SALES_USER,
];
}
public function testAssetUploadPathTraversal(): void
{
$client = $this->client;
$container = $this->getContainer();
// Get CSRF token
$csrfToken = $container->get('security.csrf.token_manager')->getToken('mautic_ajax_post')->getValue();
// Create a temporary file
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tempFile, '111');
// Prepare the file for upload
$uploadedFile = new UploadedFile(
$tempFile,
'test.txt',
'text/plain',
null,
true
);
$tmpDir = 'tmp_'.substr(md5(uniqid()), 0, 13);
$client->request(
'POST',
'/s/_uploader/asset/upload',
['tempId' => '../../'.$tmpDir],
['file' => $uploadedFile],
[
'HTTP_X-Requested-With' => 'XMLHttpRequest',
'HTTP_X-CSRF-Token' => $csrfToken,
]
);
$response = $client->getResponse();
// Assert response is successful
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
// Decode JSON response
$responseData = json_decode($response->getContent(), true);
// Assert the response contains expected keys
$this->assertArrayHasKey('tmpFileName', $responseData);
// Assert file was created in the correct directory
$expectedDir = $container->getParameter('mautic.upload_dir').join('/', ['', 'tmp', $tmpDir]);
$expectedFilePath = join('/', [$expectedDir, $responseData['tmpFileName']]);
$this->assertFileExists($expectedFilePath);
// Clean up
if (file_exists($expectedFilePath)) {
unlink($expectedFilePath);
}
if (is_dir($expectedDir)) {
rmdir($expectedDir);
}
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
private function getUser(string $username): User
{
$repository = $this->em->getRepository(User::class);
return $repository->findOneBy(['username' => $username]);
}
/**
* @param array<string, array<string, array<string>>> $permissions
*/
private function setPermission(User $user, array $permissions): void
{
$role = $user->getRole();
// Delete previous permissions
$this->em->createQueryBuilder()
->delete(Permission::class, 'p')
->where('p.bundle = :bundle')
->andWhere('p.role = :role_id')
->setParameters(['bundle' => 'asset', 'role_id' => $role->getId()])
->getQuery()
->execute();
// Set new permissions
$role->setIsAdmin(false);
$roleModel = static::getContainer()->get('mautic.user.model.role');
\assert($roleModel instanceof RoleModel);
$roleModel->setRolePermissions($role, $permissions);
$this->em->persist($role);
$this->em->flush();
}
public function testPostRequestWithWrongTempNameAndOriginalFileNameFileExtension(): void
{
$response = $this->client->request(
Request::METHOD_GET,
'/s/assets/new',
);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$form = $response->filter('form[name="asset"]')->form();
$data = $form->getPhpValues();
$data['asset']['tempName'] = 'image2.php';
$data['asset']['originalFileName'] = 'originalImage2.php';
$data['asset']['storageLocation'] = 'local';
$data['asset']['title'] = 'title';
$data['asset']['description'] = 'description';
$this->client->submit($form, $data);
preg_match_all('/Upload failed as the file extension, php/', $this->client->getResponse()->getContent(), $matches);
$this->assertCount(2, $matches[0]);
$this->assertStringContainsString('Upload failed as the file extension, php', $this->client->getResponse()->getContent());
}
public function testPostRequestWithWrongTempNameFileExtension(): void
{
$response = $this->client->request(
Request::METHOD_GET,
'/s/assets/new',
);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$form = $response->filter('form[name="asset"]')->form();
$data = $form->getPhpValues();
$data['asset']['tempName'] = 'image2.php';
$data['asset']['originalFileName'] = 'originalImage2.png';
$data['asset']['storageLocation'] = 'local';
$data['asset']['title'] = 'title';
$data['asset']['description'] = 'description';
$this->client->submit($form, $data);
preg_match_all('/Upload failed as the file extension, php/', $this->client->getResponse()->getContent(), $matches);
$this->assertCount(1, $matches[0]);
$this->assertStringContainsString('Upload failed as the file extension, php', $this->client->getResponse()->getContent());
}
public function testPostResquetSuccessWithCorrectFileExtension(): void
{
$response = $this->client->request(
Request::METHOD_GET,
'/s/assets/new',
);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$form = $response->filter('form[name="asset"]')->form();
$data = $form->getPhpValues();
$data['asset']['tempName'] = 'image.png';
$data['asset']['originalFileName'] = 'originalImage.png';
$data['asset']['storageLocation'] = 'local';
$data['asset']['title'] = 'title';
$data['asset']['description'] = 'description';
$this->client->submit($form, $data);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertStringNotContainsString('Upload failed as the file extension, php', $this->client->getResponse()->getContent());
}
public function testAssetWithProject(): void
{
$asset = new Asset();
$asset->setTitle('test');
$asset->setAlias('test');
$this->em->persist($asset);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/assets/edit/'.$asset->getId());
$form = $crawler->selectButton('Save')->form();
$form['asset[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedAsset = $this->em->find(Asset::class, $asset->getId());
Assert::assertSame($project->getId(), $savedAsset->getProjects()->first()->getId());
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Mautic\AssetBundle\Tests\Controller;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
class AssetDetailFunctionalTest extends MauticMysqlTestCase
{
public function testLeadViewPreventsXSS(): void
{
$title = 'aaa" onerror=alert(1) a="';
$asset = new Asset();
$asset->setTitle($title);
$asset->setAlias('dummy-alias');
$asset->setStorageLocation('local');
$asset->setPath('broken-image.jpg');
$asset->setExtension('jpg');
$this->em->persist($asset);
$this->em->flush();
$this->em->detach($asset);
$crawler = $this->client->request('GET', sprintf('/s/assets/view/%d', $asset->getId()));
$imageTag = $crawler->filter('.img-thumbnail');
$onError = $imageTag->attr('onerror');
$altProp = $imageTag->attr('alt');
Assert::assertNull($onError);
Assert::assertSame($title, $altProp);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class AssetDownloadFunctionalTest extends MauticMysqlTestCase
{
public function testDownloadOfNotFoundAsset(): void
{
$this->client->request(Request::METHOD_GET, '/s/logout');
// The 500 error happened only on the second request.
// It happened only if the device was already tracked.
$this->client->request(Request::METHOD_GET, '/asset/unicorn'); // returns 404 correctly
$this->client->request(Request::METHOD_GET, '/asset/unicorn'); // returned 500 but it should return 404
Assert::assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode());
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\Controller;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class AssetProjectSearchFunctionalTest extends AbstractProjectSearchTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void
{
$projectOne = $this->createProject('Project One');
$projectTwo = $this->createProject('Project Two');
$projectThree = $this->createProject('Project Three');
$assetAlpha = $this->createAsset('Asset Alpha');
$assetBeta = $this->createAsset('Asset Beta');
$this->createAsset('Asset Gamma');
$this->createAsset('Asset Delta');
$assetAlpha->addProject($projectOne);
$assetAlpha->addProject($projectTwo);
$assetBeta->addProject($projectTwo);
$assetBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/assets', '/s/assets']);
}
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
public static function searchDataProvider(): \Generator
{
yield 'search by one project' => [
'searchTerm' => 'project:"Project Two"',
'expectedEntities' => ['Asset Alpha', 'Asset Beta'],
'unexpectedEntities' => ['Asset Gamma', 'Asset Delta'],
];
yield 'search by one project AND asset name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['Asset Beta'],
'unexpectedEntities' => ['Asset Alpha', 'Asset Gamma', 'Asset Delta'],
];
yield 'search by one project OR asset name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['Asset Alpha', 'Asset Beta', 'Asset Gamma'],
'unexpectedEntities' => ['Asset Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['Asset Gamma', 'Asset Delta'],
'unexpectedEntities' => ['Asset Alpha', 'Asset Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['Asset Beta'],
'unexpectedEntities' => ['Asset Alpha', 'Asset Gamma', 'Asset Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['Asset Gamma', 'Asset Delta'],
'unexpectedEntities' => ['Asset Alpha', 'Asset Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['Asset Alpha', 'Asset Beta'],
'unexpectedEntities' => ['Asset Gamma', 'Asset Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['Asset Alpha', 'Asset Gamma', 'Asset Delta'],
'unexpectedEntities' => ['Asset Beta'],
];
}
private function createAsset(string $name): Asset
{
$asset = new Asset();
$asset->setTitle($name);
$asset->setAlias($name);
$this->em->persist($asset);
return $asset;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Mautic\AssetBundle\Tests\Controller;
use Mautic\AssetBundle\Entity\Download;
use Mautic\AssetBundle\Tests\Asset\AbstractAssetTestCase;
class PublicControllerFunctionalTest extends AbstractAssetTestCase
{
/**
* Download action should return the file content.
*/
public function testDownloadActionStreamByDefault(): void
{
$assetSlug = $this->asset->getId().':'.$this->asset->getAlias();
$this->client->request('GET', '/asset/'.$assetSlug);
ob_start();
$response = $this->client->getResponse();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
$this->assertResponseIsSuccessful();
$this->assertSame($this->expectedMimeType, $response->headers->get('Content-Type'));
$this->assertNotSame($this->expectedContentDisposition.$this->asset->getOriginalFileName(), $response->headers->get('Content-Disposition'));
$this->assertEquals($this->expectedPngContent, $content);
}
/**
* Download action should return the file content.
*/
public function testDownloadActionStreamIsZero(): void
{
$assetSlug = $this->asset->getId().':'.$this->asset->getAlias();
$this->client->request('GET', '/asset/'.$assetSlug.'?stream=0');
ob_start();
$response = $this->client->getResponse();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
$this->assertResponseIsSuccessful();
$this->assertSame($this->expectedContentDisposition.$this->asset->getOriginalFileName(), $response->headers->get('Content-Disposition'));
$this->assertEquals($this->expectedPngContent, $content);
}
/**
* Download action with UTM should return the file content.
*/
public function testDownloadActionWithUTM(): void
{
$this->logoutUser();
$assetSlug = $this->asset->getId().':'.$this->asset->getAlias().'?utm_source=test2&utm_medium=test3&utm_campaign=test6&utm_term=test4&utm_content=test5';
$this->client->request('GET', '/asset/'.$assetSlug);
ob_start();
$response = $this->client->getResponse();
$response->sendContent();
$content = ob_get_contents();
ob_end_clean();
$this->assertResponseIsSuccessful();
$this->assertSame($this->expectedMimeType, $response->headers->get('Content-Type'));
$this->assertNotSame($this->expectedContentDisposition.$this->asset->getOriginalFileName(), $response->headers->get('Content-Disposition'));
$this->assertEquals($this->expectedPngContent, $content);
$downloadRepo = $this->em->getRepository(Download::class);
$download = $downloadRepo->findOneBy(['asset' => $this->asset]);
\assert($download instanceof Download);
$this->assertSame('test2', $download->getUtmSource());
$this->assertSame('test3', $download->getUtmMedium());
$this->assertSame('test4', $download->getUtmTerm());
$this->assertSame('test5', $download->getUtmContent());
$this->assertSame('test6', $download->getUtmCampaign());
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Mautic\AssetBundle\Tests\Controller;
use Mautic\AssetBundle\Tests\Asset\AbstractAssetTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class UploadControllerFunctionalTest extends AbstractAssetTestCase
{
public function testUploadWithWrongMimetype(): void
{
// Create a php file with the content of phpinfo
$assetsPath = $this->client->getKernel()->getContainer()->getParameter('mautic.upload_dir');
$fileName = 'image2.png';
$filePath = $assetsPath.'/'.$fileName;
if (file_exists($filePath)) {
unlink($filePath);
}
copy('index.php', $filePath);
$binaryFile = new UploadedFile($filePath, $fileName, 'application/x-httpd-php', null, true);
$tmpId = 'tempId_'.time();
// Upload the file
$this->client->request(
Request::METHOD_POST,
'/s/_uploader/asset/upload',
[
'tempId' => $tmpId,
],
[
'file' => $binaryFile,
]
);
$response = $this->client->getResponse();
$this->assertStringContainsString('Upload failed as the file mimetype', $response->getContent());
$this->assertStringContainsString('text\/x-php is not allowed', $response->getContent());
unlink($filePath);
}
public function testSuccessUploadWithPng(): void
{
// Create a temporary PNG file
// Create a php file with the content of phpinfo
$assetsPath = $this->client->getKernel()->getContainer()->getParameter('mautic.upload_dir');
$assetsPathFrom = $this->client->getKernel()->getContainer()->getParameter('mautic.application_dir').'/app/assets/images/mautic_logo_db64.png';
$fileName = 'image3.png';
$filePath = $assetsPath.'/'.$fileName;
copy($assetsPathFrom, $filePath);
// Create an UploadedFile instance with the correct MIME type
$uploadedFile = new UploadedFile($filePath, $fileName, 'image/png', null, true);
$tmpId = 'tempId_'.time();
// Perform the request with the file
$this->client->request(
'POST',
'/s/_uploader/asset/upload',
['tempId' => $tmpId],
['file' => $uploadedFile]
);
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertStringContainsString('state":1', $this->client->getResponse()->getContent());
if (file_exists($filePath)) {
unlink($filePath);
}
$data = json_decode($this->client->getResponse()->getContent(), true);
unlink($assetsPath.'/tmp/'.$tmpId.'/'.$data['tmpFileName']);
rmdir($assetsPath.'/tmp/'.$tmpId);
}
public function testUploadWithWrongExtension(): void
{
// Create a php file with the content of phpinfo
$assetsPath = $this->client->getKernel()->getContainer()->getParameter('mautic.upload_dir');
$assetsPathFrom = $this->client->getKernel()->getContainer()->getParameter('mautic.application_dir').'/app/assets/images/mautic_logo_db64.png';
$fileName = 'image2.php';
$filePath = $assetsPath.'/'.$fileName;
if (file_exists($filePath)) {
unlink($filePath);
}
copy($assetsPathFrom, $filePath);
$binaryFile = new UploadedFile($filePath, $fileName, 'image/png', null, true);
$tmpId = 'tempId_'.time();
// Upload the file
$this->client->request(
Request::METHOD_POST,
'/s/_uploader/asset/upload',
[
'tempId' => $tmpId,
],
[
'file' => $binaryFile,
]
);
$response = $this->client->getResponse();
$this->assertStringContainsString('Upload failed as the file extension', $response->getContent());
$this->assertStringContainsString('Upload failed as the file extension, php,', $response->getContent());
unlink($filePath);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\DataFixtures;
use Mautic\AssetBundle\DataFixtures\ORM\LoadAssetData;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
class LoadAssetDataTest extends MauticMysqlTestCase
{
public function testLoadFixtures(): void
{
$this->loadFixtures([LoadAssetData::class]);
$asset = $this->em->getRepository(Asset::class)->findOneBy(
['title' => '@TOCHANGE: Asset1 Title'],
['id' => 'DESC']
);
self::assertInstanceOf(Asset::class, $asset);
self::assertEquals('asset1', $asset->getAlias());
self::assertEquals('@TOCHANGE: Asset1 Original File Name', $asset->getOriginalFileName());
self::assertEquals('fdb8e28357b02d12d068de3e5661832e21bc08ec.doc', $asset->getPath());
self::assertEquals(1, $asset->getDownloadCount());
self::assertEquals(1, $asset->getUniqueDownloadCount());
self::assertEquals(1, $asset->getRevision());
self::assertEquals('en', $asset->getLanguage());
}
public function testLoadFixturesOrder(): void
{
$loadAssetData = new LoadAssetData();
self::assertEquals(10, $loadAssetData->getOrder());
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\Entity;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Entity\AssetRepository;
use Mautic\CoreBundle\Test\Doctrine\RepositoryConfiguratorTrait;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class AssetRepositoryTest extends TestCase
{
use RepositoryConfiguratorTrait;
private function getRepository(): AssetRepository
{
$repository = $this->configureRepository(Asset::class);
$this->connection->method('createQueryBuilder')->willReturnCallback(fn () => new QueryBuilder($this->connection));
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnCallback(fn ($id) => match ($id) {
'mautic.asset.asset.searchcommand.isexpired' => 'is:expired',
'mautic.asset.asset.searchcommand.ispending' => 'is:pending',
default => $id,
});
$repository->setTranslator($translator);
return $repository;
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataExpirationFilters')]
public function testAddSearchCommandWhereClauseHandlesExpirationFilters(string $command, string $expected): void
{
$repository = $this->getRepository();
$qb = $this->connection->createQueryBuilder();
$filter = (object) ['command' => $command, 'string' => '', 'not' => false, 'strict' => false];
$method = new \ReflectionMethod(AssetRepository::class, 'addSearchCommandWhereClause');
$method->setAccessible(true);
[$expr, $params] = $method->invoke($repository, $qb, $filter);
self::assertSame($expected, (string) $expr);
self::assertSame(['par1' => true], $params);
}
/**
* @return iterable<array{0: string, 1: string}>
*/
public static function dataExpirationFilters(): iterable
{
yield ['is:expired', "(a.isPublished = :par1 AND a.publishDown IS NOT NULL AND a.publishDown <> '' AND a.publishDown < CURRENT_TIMESTAMP())"];
yield ['is:pending', "(a.isPublished = :par1 AND a.publishUp IS NOT NULL AND a.publishUp <> '' AND a.publishUp > CURRENT_TIMESTAMP())"];
}
public function testGetSearchCommandsContainsExpirationFilters(): void
{
$repository = $this->getRepository();
$commands = $repository->getSearchCommands();
self::assertContains('mautic.asset.asset.searchcommand.isexpired', $commands);
self::assertContains('mautic.asset.asset.searchcommand.ispending', $commands);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\AssetBundle\Entity\DownloadRepository;
use Mautic\AssetBundle\EventListener\DetermineWinnerSubscriber;
use Mautic\CoreBundle\Event\DetermineWinnerEvent;
use Mautic\PageBundle\Entity\Page;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Contracts\Translation\TranslatorInterface;
class DetermineWinnerSubscriberTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject|EntityManagerInterface
*/
private MockObject $em;
/**
* @var MockObject|TranslatorInterface
*/
private MockObject $translator;
private DetermineWinnerSubscriber $subscriber;
protected function setUp(): void
{
parent::setUp();
$this->em = $this->createMock(EntityManagerInterface::class);
$this->translator = $this->createMock(TranslatorInterface::class);
$this->subscriber = new DetermineWinnerSubscriber($this->em, $this->translator);
}
public function testOnDetermineDownloadRateWinner(): void
{
$parentMock = $this->createMock(Page::class);
$childMock = $this->createMock(Page::class);
$children = [2 => $childMock];
$repoMock = $this->createMock(DownloadRepository::class);
$parameters = ['parent' => $parentMock, 'children' => $children];
$event = new DetermineWinnerEvent($parameters);
$startDate = new \DateTime();
$transDownloads = 'downloads';
$transHits = 'hits';
$counts = [
1 => [
'count' => 20,
'id' => 1,
'name' => 'Test 5',
'total' => 100,
],
2 => [
'count' => 25,
'id' => 2,
'name' => 'Test 6',
'total' => 150,
],
];
$this->translator->method('trans')
->willReturnOnConsecutiveCalls($transDownloads, $transHits);
$this->em->expects($this->once())
->method('getRepository')
->willReturn($repoMock);
$parentMock->expects($this->any())
->method('isPublished')
->willReturn(true);
$childMock->expects($this->any())
->method('isPublished')
->willReturn(true);
$parentMock->expects($this->any())
->method('getId')
->willReturn(1);
$childMock->expects($this->any())
->method('getId')
->willReturn(2);
$parentMock->expects($this->once())
->method('getVariantStartDate')
->willReturn($startDate);
$repoMock->expects($this->once())
->method('getDownloadCountsByPage')
->with([1, 2], $startDate)
->willReturn($counts);
$this->subscriber->onDetermineDownloadRateWinner($event);
$expectedData = [
$transDownloads => [$counts[1]['count'], $counts[2]['count']],
$transHits => [$counts[1]['total'], $counts[2]['total']],
];
$abTestResults = $event->getAbTestResults();
$this->assertEquals($abTestResults['winners'], [1]);
$this->assertEquals($abTestResults['support']['data'], $expectedData);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\EventListener;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Entity\Download;
use Mautic\LeadBundle\Entity\DoNotContact;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\ReportBundle\Tests\Functional\AbstractReportSubscriberTestCase;
class ReportSubscriberFunctionalTest extends AbstractReportSubscriberTestCase
{
public function testAssetDownloadReportWithDncListColumn(): void
{
$leads[] = $this->createContact('test1@example.com');
$leads[] = $this->createContact('test2@example.com');
$leads[] = $this->createContact('test3@example.com');
$this->em->flush();
$this->createDnc('email', $leads[0], DoNotContact::BOUNCED);
$this->createDnc('email', $leads[1], DoNotContact::MANUAL);
$this->createDnc('email', $leads[2], DoNotContact::UNSUBSCRIBED);
$this->createDnc('sms', $leads[2], DoNotContact::MANUAL);
$this->em->flush();
$asset = $this->createAsset();
$this->emulateAssetDownload($asset, $leads[0]);
$this->emulateAssetDownload($asset, $leads[1]);
$this->emulateAssetDownload($asset, $leads[2]);
$report = $this->createReport(
source: 'asset.downloads',
columns: ['l.id', 'a.id', 'a.title', 'dnc_preferences'],
filters: [
[
'column' => 'dnc_preferences',
'glue' => 'and',
'dynamic' => null,
'condition' => 'in',
'value' => [
'email:'.DoNotContact::UNSUBSCRIBED,
'email:'.DoNotContact::BOUNCED,
],
],
],
order: [['column' => 'l.id', 'direction' => 'ASC']]
);
$expectedReport = [
[(string) $leads[0]->getId(), (string) $asset->getId(), $asset->getTitle(), 'DNC Bounced: Email'],
[(string) $leads[2]->getId(), (string) $asset->getId(), $asset->getTitle(), 'DNC Manually Unsubscribed: Text Message, DNC Unsubscribed: Email'],
];
$this->verifyReport($report->getId(), $expectedReport);
$this->verifyApiReport($report->getId(), $expectedReport);
}
private function createAsset(): Asset
{
$asset = new Asset();
$asset->setTitle('test');
$asset->setAlias('test');
$asset->setDateAdded(new \DateTime('2020-02-07 20:29:02'));
$asset->setDateModified(new \DateTime('2020-03-21 20:29:02'));
$asset->setCreatedByUser('Test User');
$this->em->persist($asset);
$this->em->flush();
return $asset;
}
private function emulateAssetDownload(Asset $asset, Lead $contact): Download
{
$assetDownload = new Download();
$assetDownload->setAsset($asset);
$assetDownload->setLead($contact);
$assetDownload->setDateDownload(new \DateTime());
$assetDownload->setCode(200);
$assetDownload->setTrackingId(random_int(1, 99999));
$this->em->persist($assetDownload);
$this->em->flush();
return $assetDownload;
}
private function createContact(string $email): Lead
{
$contact = new Lead();
$contact->setEmail($email);
$this->em->persist($contact);
return $contact;
}
public function createDnc(string $channel, Lead $contact, int $reason): DoNotContact
{
$dnc = new DoNotContact();
$dnc->setChannel($channel);
$dnc->setLead($contact);
$dnc->setReason($reason);
$dnc->setDateAdded(new \DateTime());
$this->em->persist($dnc);
return $dnc;
}
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\EventListener;
use Mautic\AssetBundle\Entity\DownloadRepository;
use Mautic\AssetBundle\EventListener\ReportSubscriber;
use Mautic\ChannelBundle\Helper\ChannelListHelper;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Model\CompanyReportData;
use Mautic\LeadBundle\Report\DncReportService;
use Mautic\LeadBundle\Segment\Query\QueryBuilder;
use Mautic\ReportBundle\Entity\Report;
use Mautic\ReportBundle\Event\ReportBuilderEvent;
use Mautic\ReportBundle\Event\ReportGeneratorEvent;
use Mautic\ReportBundle\Helper\ReportHelper;
use PHPUnit\Framework\Assert;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ReportSubscriberTest extends \PHPUnit\Framework\TestCase
{
private ChannelListHelper $channelListHelper;
/**
* @var CompanyReportData|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $companyReportData;
/**
* @var DownloadRepository|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $downloadRepository;
/**
* @var QueryBuilder|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $queryBuilder;
/**
* @var DncReportService|\PHPUnit\Framework\MockObject\MockObject
*/
private \PHPUnit\Framework\MockObject\MockObject $dncReportService;
private ReportHelper $reportHelper;
public function setUp(): void
{
$this->queryBuilder = $this->createMock(QueryBuilder::class);
$this->channelListHelper = new ChannelListHelper($this->createMock(EventDispatcherInterface::class), $this->createMock(Translator::class));
$this->reportHelper = new ReportHelper($this->createMock(EventDispatcherInterface::class));
$this->companyReportData = $this->createMock(CompanyReportData::class);
$this->downloadRepository = $this->createMock(DownloadRepository::class);
$this->dncReportService = $this->createMock(DncReportService::class);
}
public function testOnReportBuilderWithUnknownContext(): void
{
$companyReportData = new class extends CompanyReportData {
public function __construct()
{
}
};
$downloadRepository = new class extends DownloadRepository {
public function __construct()
{
}
};
$event = new class extends ReportBuilderEvent {
public function __construct()
{
$this->context = 'unicorn';
}
};
$reportSubscriber = new ReportSubscriber($companyReportData, $downloadRepository, $this->dncReportService);
$reportSubscriber->onReportBuilder($event);
Assert::assertSame([], $event->getTables());
}
public function testOnReportBuilderWithAssetDownloadContext(): void
{
$companyReportData = new class extends CompanyReportData {
public function __construct()
{
}
/**
* @return array<mixed>
*/
public function getCompanyData(): array
{
return [];
}
};
$downloadRepository = new class extends DownloadRepository {
public function __construct()
{
}
};
$event = new ReportBuilderEvent($this->createTranslatorMock(), $this->channelListHelper, ReportSubscriber::CONTEXT_ASSET_DOWNLOAD, [], $this->reportHelper);
$reportSubscriber = new ReportSubscriber($companyReportData, $downloadRepository, $this->dncReportService);
$reportSubscriber->onReportBuilder($event);
Assert::assertSame(
[
'alias' => 'download_count',
'label' => '[trans]mautic.asset.report.download_count[/trans]',
'type' => 'int',
],
$event->getTables()['assets']['columns']['a.download_count']
);
Assert::assertSame(
[
'alias' => 'unique_download_count',
'label' => '[trans]mautic.asset.report.unique_download_count[/trans]',
'type' => 'int',
],
$event->getTables()['assets']['columns']['a.unique_download_count']
);
Assert::assertSame(
[
'alias' => 'download_count',
'label' => '[trans]mautic.asset.report.download_count[/trans]',
'type' => 'int',
'formula' => 'COUNT(ad.id)',
],
$event->getTables()['asset.downloads']['columns']['a.download_count']
);
Assert::assertSame(
[
'alias' => 'unique_download_count',
'label' => '[trans]mautic.asset.report.unique_download_count[/trans]',
'type' => 'int',
'formula' => 'COUNT(DISTINCT ad.lead_id)',
],
$event->getTables()['asset.downloads']['columns']['a.unique_download_count']
);
}
private function createTranslatorMock(): TranslatorInterface
{
return new class implements TranslatorInterface {
/**
* @param array<int|string> $parameters
*/
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return '[trans]'.$id.'[/trans]';
}
public function getLocale(): string
{
return 'en';
}
};
}
public function testGroupByDefaultConfigured(): void
{
$report = new Report();
$report->setSource(ReportSubscriber::CONTEXT_ASSET_DOWNLOAD);
$event = new ReportGeneratorEvent($report, [], $this->queryBuilder, $this->channelListHelper);
$subscriber = new ReportSubscriber($this->companyReportData, $this->downloadRepository, $this->dncReportService);
$this->queryBuilder->method('from')->willReturn($this->queryBuilder);
$this->queryBuilder->expects($this->once())
->method('groupBy')
->with('ad.id');
$this->assertFalse($event->hasGroupBy());
$subscriber->onReportGenerate($event);
}
public function testGroupByNotDefaultConfigured(): void
{
$report = new Report();
$report->setSource(ReportSubscriber::CONTEXT_ASSET_DOWNLOAD);
$this->queryBuilder->method('from')->willReturn($this->queryBuilder);
$report->setGroupBy(['a.id' => 'desc']);
$event = new ReportGeneratorEvent($report, [], $this->queryBuilder, $this->channelListHelper);
$subscriber = new ReportSubscriber($this->companyReportData, $this->downloadRepository, $this->dncReportService);
$subscriber->onReportGenerate($event);
$this->assertTrue($event->hasGroupBy());
}
}

View File

@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace Mautic\AssetBundle\Tests\Model;
use Doctrine\ORM\EntityManager;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Entity\Asset;
use Mautic\AssetBundle\Entity\AssetRepository;
use Mautic\AssetBundle\Entity\Download;
use Mautic\AssetBundle\Model\AssetModel;
use Mautic\CacheBundle\Cache\CacheProvider;
use Mautic\CategoryBundle\Model\CategoryModel;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\LeadBundle\Tracker\Factory\DeviceDetectorFactory\DeviceDetectorFactory;
use Mautic\LeadBundle\Tracker\Service\DeviceCreatorService\DeviceCreatorService;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\ServerBag;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AssetModelTest extends \PHPUnit\Framework\TestCase
{
private AssetModel $assetModel;
private CoreParametersHelper&MockObject $coreParametersHelper;
private ContainerInterface&MockObject $container;
private CacheProvider $cacheProvider;
private LeadModel&MockObject $leadModel;
private CategoryModel&MockObject $categoryModel;
private RequestStack&MockObject $requestStack;
private IpLookupHelper&MockObject $ipLookupHelper;
private DeviceDetectorFactory $deviceDetectorFactory;
private DeviceCreatorService $deviceCreatorService;
private DeviceTrackingServiceInterface&MockObject $deviceTrackingService;
private ContactTracker&MockObject $contactTracker;
private EntityManager&MockObject $entityManager;
private CorePermissions&MockObject $corePermissions;
private EventDispatcherInterface&MockObject $eventDispatcher;
private MockObject&UrlGeneratorInterface $urlGenerator;
private Translator&MockObject $translator;
private UserHelper&MockObject $userHelper;
private LoggerInterface&MockObject $logger;
protected function setUp(): void
{
parent::setUp();
$this->coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$this->coreParametersHelper->expects($this->once())
->method('get')
->with($this->equalTo('max_size'))
->willReturn('2MB');
$this->container = $this->createMock(ContainerInterface::class);
$this->cacheProvider = new CacheProvider($this->coreParametersHelper, $this->container);
$this->leadModel = $this->createMock(LeadModel::class);
$this->categoryModel = $this->createMock(CategoryModel::class);
$this->requestStack = $this->createMock(RequestStack::class);
$this->ipLookupHelper = $this->createMock(IpLookupHelper::class);
$this->deviceDetectorFactory = new DeviceDetectorFactory($this->cacheProvider);
$this->deviceCreatorService = new DeviceCreatorService();
$this->deviceTrackingService = $this->createMock(DeviceTrackingServiceInterface::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->entityManager = $this->createMock(EntityManager::class);
$this->corePermissions = $this->createMock(CorePermissions::class);
$this->eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$this->urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$this->translator = $this->createMock(Translator::class);
$this->userHelper = $this->createMock(UserHelper::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->assetModel = new AssetModel(
$this->leadModel,
$this->categoryModel,
$this->requestStack,
$this->ipLookupHelper,
$this->deviceCreatorService,
$this->deviceDetectorFactory,
$this->deviceTrackingService,
$this->contactTracker,
$this->entityManager,
$this->corePermissions,
$this->eventDispatcher,
$this->urlGenerator,
$this->translator,
$this->userHelper,
$this->logger,
$this->coreParametersHelper,
);
}
/**
* Test that TrackDownload works only with a request.
*/
public function testTrackDownloadRequest(): void
{
$asset = new Asset();
$this->corePermissions->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$this->requestStack->expects($this->once())
->method('getCurrentRequest')
->willReturn(null);
$this->entityManager->expects($this->never())
->method('persist');
$this->entityManager->expects($this->never())
->method('flush');
$this->entityManager->expects($this->never())
->method('detach');
$this->assetModel->trackDownload($asset);
}
/**
* Test that TrackDownload works successfully.
*/
public function testTrackDownload(): void
{
$asset = new Asset();
$lead = new Lead();
$this->corePermissions->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$request = $this->createMock(Request::class);
$serverBag = $this->createMock(ServerBag::class);
$serverBag->expects($this->once())
->method('get')
->with($this->equalTo('HTTP_REFERER'))
->willReturn('http://localhost');
$request->server = $serverBag;
$matcher = $this->exactly(6);
$request->expects($matcher)
->method('get')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertEquals('utm_campaign', $parameters[0]);
return 'test_utm_campaign';
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertEquals('utm_content', $parameters[0]);
return 'test_utm_content';
}
if (3 === $matcher->numberOfInvocations()) {
$this->assertEquals('utm_medium', $parameters[0]);
return 'test_utm_medium';
}
if (4 === $matcher->numberOfInvocations()) {
$this->assertEquals('utm_source', $parameters[0]);
return 'test_utm_source';
}
if (5 === $matcher->numberOfInvocations()) {
$this->assertEquals('utm_term', $parameters[0]);
return 'test_utm_term';
}
if (6 === $matcher->numberOfInvocations()) {
$this->assertEquals('ct', $parameters[0]);
return false;
}
});
$this->requestStack->expects($this->once())
->method('getCurrentRequest')
->willReturn($request);
$this->deviceTrackingService->expects($this->once())
->method('isTracked')
->willReturn(false);
$this->contactTracker->expects($this->once())
->method('getContact')
->willReturn($lead);
$this->deviceTrackingService->expects($this->once())
->method('getTrackedDevice')
->willReturn(null);
$assetRepository = $this->createMock(AssetRepository::class);
$this->entityManager->expects($this->once())
->method('getRepository')
->with($this->equalTo(Asset::class))
->willReturn($assetRepository);
$assetRepository->expects($this->once())
->method('upDownloadCount')
->with(
$this->equalTo($asset->getId()),
$this->equalTo(1),
$this->equalTo(true),
);
$ipAddress = new IpAddress('127.0.0.1');
$this->ipLookupHelper->expects($this->once())
->method('getIpAddress')
->willReturn($ipAddress);
$this->eventDispatcher->expects($this->once())
->method('hasListeners')
->with($this->equalTo(AssetEvents::ASSET_ON_LOAD))
->willReturn(false);
/** @var ?Download $download */
$download = null;
$this->entityManager->expects($this->once())
->method('persist')
->with($this->callback(function ($downloadPersist) use (&$download) {
$download = $downloadPersist;
return $download instanceof Download;
}));
$this->entityManager->expects($this->once())
->method('flush');
$this->entityManager->expects($this->once())
->method('detach')
->with($this->callback(function ($downloadDetach) use (&$download) {
$this->assertSame($downloadDetach, $download);
return true;
}));
$this->assetModel->trackDownload($asset);
$this->assertEquals('test_utm_campaign', $download->getUtmCampaign());
$this->assertEquals('test_utm_content', $download->getUtmContent());
$this->assertEquals('test_utm_medium', $download->getUtmMedium());
$this->assertEquals('test_utm_source', $download->getUtmSource());
$this->assertEquals('test_utm_term', $download->getUtmTerm());
$this->assertEquals('200', $download->getCode());
$this->assertEquals($ipAddress, $download->getIpAddress());
$this->assertEquals($lead, $download->getLead());
$this->assertEquals($asset, $download->getAsset());
$this->assertEquals('http://localhost', $download->getReferer());
}
}

View File

@@ -0,0 +1,2 @@
mautic.asset.asset.error.notfound="No asset with an id of %id% was found!"
mautic.asset.asset.notice.batch_deleted="%count% assets have been deleted!"

View File

@@ -0,0 +1,127 @@
mautic.asset.abtest.criteria="Asset Stats"
mautic.asset.abtest.criteria.downloads="Download rate"
mautic.asset.abtest.downloads="%count% downloads"
mautic.asset.abtest.label.downloads="Number of downloads"
mautic.asset.abtest.label.hits="Number of unique asset downloads"
mautic.asset.abtest.label.sentemils="Number of sent emails"
mautic.asset.actions="Asset actions"
mautic.asset.asset="Asset"
mautic.asset.click_to_zoom="Click on the image to zoom"
mautic.asset.click_to_view_full_size="View full size"
mautic.asset.no_preview="No preview available"
mautic.asset.open_preview="Open preview"
mautic.protip.assets.gating="Gate valuable assets behind a form to capture lead information before allowing the download."
mautic.protip.assets.naming="Use consistent naming conventions to keep assets organized"
mautic.protip.assets.repurpose="Repurpose high-performing assets across different channels and campaigns"
mautic.protip.assets.track="Track asset performance metrics in the dashboard widgets to identify top-performing content"
mautic.asset.asset.searchcommand.lang="lang"
mautic.asset.asset.searchcommand.lang.description="Filters assets by a specific language code"
mautic.asset.asset.searchcommand.isexpired.description="Filters for assets that have passed their expiration date"
mautic.asset.asset.searchcommand.isexpired="is:expired"
mautic.asset.asset.searchcommand.ispending.description="Filters for assets scheduled for future availability"
mautic.asset.asset.searchcommand.ispending="is:pending"
mautic.asset.asset.downloads.total="Total downloads"
mautic.asset.asset.downloads.unique="Unique downloads"
mautic.asset.asset.downloads.total.all_time="All downloads over time since this asset was created"
mautic.asset.asset.downloads.unique.all_time="Unlike total downloads, this counts each contact only once"
mautic.asset.asset.error.missing.remote.path="A remote URL must be specified when remote storage is selected."
mautic.asset.asset.form.confirmbatchdelete="Delete the selected assets?"
mautic.asset.asset.form.confirmdelete="Delete the asset, %name%?"
mautic.asset.asset.form.file.upload="Upload a file (max filesize allowed = %max%)"
mautic.asset.asset.form.language.help="Select language of the asset."
mautic.asset.asset.form.remotePath="Remote URL"
mautic.asset.asset.form.storageLocation="Storage Location"
mautic.asset.asset.form.storageLocation.local="Local"
mautic.asset.asset.form.storageLocation.remote="Remote"
mautic.asset.asset.form.disallow.crawlers="Block search engines from indexing this file"
mautic.asset.asset.form.disallow.crawlers.descr="If you don't want to index files like PDF, DOCX etc, this option will disallow search bots by using the X-Robots-Tag HTTP header."
mautic.asset.asset.help.alias="Letters and numbers (hyphens allowed) used for URL generation of this asset. A unique alias based on the title will be autogenerated if left empty."
mautic.asset.asset.menu.edit="Edit Asset"
mautic.asset.asset.menu.new="New Asset"
mautic.asset.asset.preview="Preview"
mautic.asset.asset.size="Filesize"
mautic.asset.asset.submitaction.downloadfile="Download an asset"
mautic.asset.asset.submitaction.downloadfile.msg="<br />Your download should start within 5 seconds. If it does not, <a href='%url%'>click here</a>.<script>setTimeout(function(){window.location='%url%';}, 5000);</script>"
mautic.asset.asset.submitaction.downloadfile_descr="Download the selected asset upon submitting the form."
mautic.asset.asset.thead.download.count="Download count"
mautic.asset.asset.url="Download URL"
mautic.asset.assets="Assets"
mautic.asset.campaign.event.assets="Limit to Assets"
mautic.asset.campaign.event.assets.descr="Select the assets this trigger applies to. If none are selected, the event will trigger for any asset."
mautic.asset.campaign.event.download="Downloads asset"
mautic.asset.campaign.event.download_descr="Trigger actions upon downloading an asset."
mautic.asset.config.form.allowed.extensions="Allowed file extensions"
mautic.asset.config.form.allowed.extensions.tooltip="Comma separated list of file extensions. Only files with specified file extensions will be able to upload."
mautic.asset.config.form.upload.dir="Path to the asset directory"
mautic.asset.config.form.upload.dir.tooltip="Set the absolute path to where assets should be uploaded to. %kernel.project_dir%/app can be used as a placeholder for the app directory in the public web root. It is advised to use a directory outside of the public web root to prevent assets from being accessible by the public."
mautic.asset.config.form.max.size="Maximum size (MB)"
mautic.asset.config.form.max.size.tooltip="Set the maximum size of uploaded assets in MB (Megabytes). Default value is 6MB."
mautic.asset.downloadcount="Download count"
mautic.asset.drop.file.here="Drop the file here or click to browse and select the file."
mautic.asset.error.file.failed="File failed to upload."
mautic.asset.event.download="Asset downloaded"
mautic.asset.filename.local="Local filename"
mautic.asset.filename.original="Original filename"
mautic.asset.filename.remote="Remote filename"
mautic.asset.form.submit.assets="Asset"
mautic.asset.form.submit.assets_descr="Choose the asset to be downloaded."
mautic.asset.form.submit.latest.category="Use the latest asset from the category"
mautic.asset.form.submit.latest.category_descr="If 'Use latest file from the category' Asset option is selected then the action will download the latest asset from the selected category."
mautic.asset.graph.line.downloads="Downloads"
mautic.asset.graph.pie.statuses="Download HTTP statuses"
mautic.asset.no_audio_support="Your browser does not support audio."
mautic.asset.no_video_support="Your browser does not support video."
mautic.asset.noresults.tip="Assets can be white papers, PDFs, images, docs, eBooks, or pretty much any electronic document that you want to distribute to customers. Want to offer an asset after a customer has submitted a form? Easy! Add the 'Download an asset' action when building the form."
mautic.asset.permissions.assets="Assets - User has access to"
mautic.asset.permissions.header="Asset Permissions"
mautic.asset.onboarding.heading="Share & monitor your digital resources"
mautic.asset.onboarding.subheading="Assets are downloadable files like PDFs, ebooks, guides, or videos that you offer to your contacts."
mautic.asset.onboarding.copy="Use them to deliver value, generate leads, and understand your audience's interests based on what they choose to download."
mautic.asset.point.action.assets="Limit to the selected assets"
mautic.asset.point.action.assets.descr="Select the assets this action applies to. If none are selected, it'll apply to any asset."
mautic.asset.point.action.download="Downloads an asset"
mautic.asset.point.action.download_descr="Update the contact's points when an asset is downloaded."
mautic.asset.remote.file.browse="Browse Remote Files"
mautic.asset.remote.no_results="No remote files found."
mautic.asset.remote.select_service="Select the service to the left. If none are listed, configure the Cloud Storage addon."
mautic.asset.report.download.code="Response code"
mautic.asset.report.download.date_download="Date downloaded"
mautic.asset.report.download_count="Download count"
mautic.asset.report.downloads.table="Asset Downloads"
mautic.asset.report.unique_download_count="Unique download count"
mautic.asset.stage.action.download="Download asset"
mautic.asset.table.most.downloaded="Most downloaded assets"
mautic.asset.table.top.referrers="Top referrers"
mautic.campaign.asset.download="Asset downloaded"
mautic.config.AssetBundle.upload_dir="Upload Directory"
mautic.config.tab.assetconfig="Asset Settings"
mautic.core.config.header.assetconfig.description="Manage file storage, size limits, and allowed file types for uploaded assets."
mautic.asset.dashboard.widgets="Asset Widgets"
mautic.asset.unique="Unique"
mautic.asset.repetitive="Repetitive"
mautic.widget.asset.downloads.in.time="Downloads in time"
mautic.widget.unique.vs.repetitive.downloads="Unique vs repetitive downloads"
mautic.widget.popular.assets="Popular assets"
mautic.widget.created.assets="Created assets"
mautic.report.group.assets="Assets"
mautic.asset.asset.help.searchcommands="<strong>Search commands</strong><br />ids:ID1,ID2 (comma separated IDs, no spaces)<br />is:mine<br />is:published<br />is:unpublished<br />is:expired<br />is:pending<br />name:*<br />is:uncategorized<br />category:{category alias}<br />project:\"Project Name\""
mautic.asset.asset.error.file.mimetype="The file type is not allowed."
mautic.asset.type.excel="Excel"
mautic.asset.type.word="Word"
mautic.asset.type.pdf="PDF"
mautic.asset.type.audio="Audio"
mautic.asset.type.zip="Zip"
mautic.asset.type.image="Image"
mautic.asset.type.code="Code"
mautic.asset.type.text="Text"
mautic.asset.type.ppt="Presentation"
mautic.asset.type.video="Video"
mautic.asset.type.fallback="File"
mautic.asset.tag.disallow.label="Block search engines from indexing this file"
mautic.asset.tag.storage.local="Local storage"
mautic.asset.tag.storage.remote="Remote storage"
mautic.asset.tag.language="Language: %lang%"
mautic.report.source.assets="Assets"
mautic.report.source.asset.downloads="Asset Downloads"
mautic.asset.preview.ariaLabel="Preview for %title%"
mautic.asset.preview.pdf_iframe_title = "PDF preview for %title%"

View File

@@ -0,0 +1,9 @@
mautic.asset.asset.error.missing.file="A file must be uploaded before Asset is saved when local storage is selected."
mautic.asset.asset.error.missing.title="Assset Title is required."
mautic.asset.asset.error.missing.remote.path="A remote URL must be specified when remote storage is selected."
mautic.asset.asset.error.file.size="Upload failed as the file is %fileSize% MB which exceeds the maximum allowed file size of %maxSize% MB. This setting can be changed in the Configuration."
mautic.asset.asset.error.file.extension="Upload failed as the file extension, %fileExtension%, is not in the list of allowed extensions (%extensions%). This setting can be changed in the Configuration."
mautic.asset.asset.error.file.extension.js="Upload failed as the file extension is not in the list of allowed extensions (%extensions%). This setting can be changed in the Configuration."
mautic.asset.validation.error.url="The remote should be a valid URL."
mautic.asset.asset.error.file.mimetype="Upload failed as the file mimetype, %fileMimetype% is not allowed. Allowed file types are %mimetypes%."
mautic.asset.asset.error.invalid.mimetype="Upload failed as the file mimetype, %fileMimetype% is not allowed. Allowed file types are %mimetypes%."