Merge remote-tracking branch 'origin/v11/dev' into v12/dev

This commit is contained in:
nikolajlauridsen
2023-07-21 10:17:57 +02:00
6 changed files with 367 additions and 119 deletions

View File

@@ -86,7 +86,7 @@ public class DatabaseSchemaCreator
};
private readonly IUmbracoDatabase _database;
private readonly IOptionsMonitor<InstallDefaultDataSettings> _defaultDataCreationSettings;
private readonly IOptionsMonitor<InstallDefaultDataSettings> _installDefaultDataSettings;
private readonly IEventAggregator _eventAggregator;
private readonly ILogger<DatabaseSchemaCreator> _logger;
private readonly ILoggerFactory _loggerFactory;
@@ -105,7 +105,7 @@ public class DatabaseSchemaCreator
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion));
_eventAggregator = eventAggregator;
_defaultDataCreationSettings = defaultDataCreationSettings;
_installDefaultDataSettings = defaultDataCreationSettings; // TODO (V13): Rename this parameter to installDefaultDataSettings.
if (_database?.SqlContext?.SqlSyntax == null)
{
@@ -165,7 +165,7 @@ public class DatabaseSchemaCreator
var dataCreation = new DatabaseDataCreator(
_database, _loggerFactory.CreateLogger<DatabaseDataCreator>(),
_umbracoVersion,
_defaultDataCreationSettings);
_installDefaultDataSettings);
foreach (Type table in _orderedTables)
{
CreateTable(false, table, dataCreation);
@@ -442,7 +442,7 @@ public class DatabaseSchemaCreator
_database,
_loggerFactory.CreateLogger<DatabaseDataCreator>(),
_umbracoVersion,
_defaultDataCreationSettings));
_installDefaultDataSettings));
}
/// <summary>

View File

@@ -226,7 +226,9 @@ namespace Umbraco.Cms.Core.PropertyEditors
if (html is not null)
{
var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(html, mediaParentId, userId, _imageUrlGenerator);
var parseAndSaveBase64Images = _pastedImages.FindAndPersistEmbeddedImages(
html, mediaParentId, userId);
var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(parseAndSaveBase64Images, mediaParentId, userId);
var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages);
rte.Value = editorValueWithMediaUrlsRemoved;
}

View File

@@ -1,8 +1,14 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using HtmlAgilityPack;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
@@ -13,6 +19,7 @@ using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
@@ -30,7 +37,11 @@ public sealed class RichTextEditorPastedImages
private readonly IShortStringHelper _shortStringHelper;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
private readonly string _tempFolderAbsolutePath;
private readonly IImageUrlGenerator _imageUrlGenerator;
private readonly ContentSettings _contentSettings;
private readonly Dictionary<string, GuidUdi> _uploadedImages = new();
[Obsolete("Use the ctor which takes an IImageUrlGenerator and IOptions<ContentSettings> instead, scheduled for removal in v14")]
public RichTextEditorPastedImages(
IUmbracoContextAccessor umbracoContextAccessor,
ILogger<RichTextEditorPastedImages> logger,
@@ -41,6 +52,33 @@ public sealed class RichTextEditorPastedImages
MediaUrlGeneratorCollection mediaUrlGenerators,
IShortStringHelper shortStringHelper,
IPublishedUrlProvider publishedUrlProvider)
: this(
umbracoContextAccessor,
logger,
hostingEnvironment,
mediaService,
contentTypeBaseServiceProvider,
mediaFileManager,
mediaUrlGenerators,
shortStringHelper,
publishedUrlProvider,
StaticServiceProvider.Instance.GetRequiredService<IImageUrlGenerator>(),
StaticServiceProvider.Instance.GetRequiredService<IOptions<ContentSettings>>())
{
}
public RichTextEditorPastedImages(
IUmbracoContextAccessor umbracoContextAccessor,
ILogger<RichTextEditorPastedImages> logger,
IHostingEnvironment hostingEnvironment,
IMediaService mediaService,
IContentTypeBaseServiceProvider contentTypeBaseServiceProvider,
MediaFileManager mediaFileManager,
MediaUrlGeneratorCollection mediaUrlGenerators,
IShortStringHelper shortStringHelper,
IPublishedUrlProvider publishedUrlProvider,
IImageUrlGenerator imageUrlGenerator,
IOptions<ContentSettings> contentSettings)
{
_umbracoContextAccessor =
umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor));
@@ -53,15 +91,132 @@ public sealed class RichTextEditorPastedImages
_mediaUrlGenerators = mediaUrlGenerators;
_shortStringHelper = shortStringHelper;
_publishedUrlProvider = publishedUrlProvider;
_imageUrlGenerator = imageUrlGenerator;
_contentSettings = contentSettings.Value;
_tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads);
}
/// <summary>
/// Used by the RTE (and grid RTE) for drag/drop/persisting images
/// Used by the RTE (and grid RTE) for converting inline base64 images to Media items.
/// </summary>
public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator)
/// <param name="html">HTML from the Rich Text Editor property editor.</param>
/// <param name="mediaParentFolder"></param>
/// <param name="userId"></param>
/// <returns>Formatted HTML.</returns>
/// <exception cref="NotSupportedException">Thrown if image extension is not allowed</exception>
internal string FindAndPersistEmbeddedImages(string html, Guid mediaParentFolder, int userId)
{
// Find all img's that has data-tmpimg attribute
// Use HTML Agility Pack - https://html-agility-pack.net
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(html);
HtmlNodeCollection? imagesWithDataUris = htmlDoc.DocumentNode.SelectNodes("//img");
if (imagesWithDataUris is null || imagesWithDataUris.Count is 0)
{
return html;
}
foreach (HtmlNode? img in imagesWithDataUris)
{
var srcValue = img.GetAttributeValue("src", string.Empty);
// Ignore src-less images
if (string.IsNullOrEmpty(srcValue))
{
continue;
}
// Take only images that have a "data:image" uri into consideration
if (!srcValue.StartsWith("data:image"))
{
continue;
}
// Create tmp image by scanning the srcValue
// the value will look like "data:image/jpg;base64,abc" where the first part
// is the mimetype and the second (after the comma) is the image blob
Match dataUriInfo = Regex.Match(srcValue, @"^data:\w+\/(?<ext>\w+)[\w\+]*?;(?<encoding>\w+),(?<data>.+)$");
// If it turns up false, it was probably a false-positive and we can't do anything with it
if (dataUriInfo.Success is false)
{
continue;
}
var ext = dataUriInfo.Groups["ext"].Value.ToLowerInvariant();
var encoding = dataUriInfo.Groups["encoding"].Value.ToLowerInvariant();
var imageData = dataUriInfo.Groups["data"].Value;
if (_contentSettings.IsFileAllowedForUpload(ext) is false)
{
// If the image format is not supported we should probably leave it be
// since the user decided to include it.
// If we accepted it anyway, they could technically circumvent the allow list for file types,
// but the user experience would not be very good if we simply failed to save the content.
// Besides, there may be other types of data uri images technically supported by a browser that we cannot handle.
_logger.LogWarning(
"Performance impact: Could not convert embedded image to a Media item because the file extension {Ext} was not allowed. HTML extract: {OuterHtml}",
ext,
img.OuterHtml.Length < 100 ? img.OuterHtml : img.OuterHtml[..100]); // only log the first 100 chars because base64 images can be very long
continue;
}
// Create an unique folder path to help with concurrent users to avoid filename clash
var imageTempPath =
_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads + Path.DirectorySeparatorChar + Guid.NewGuid());
// Ensure image temp path exists
if (Directory.Exists(imageTempPath) is false)
{
Directory.CreateDirectory(imageTempPath);
}
// To get the filename, we simply manipulate the mimetype into a filename
var filePath = $"image.{ext}";
var safeFileName = filePath.ToSafeFileName(_shortStringHelper);
var tmpImgPath = imageTempPath + Path.DirectorySeparatorChar + safeFileName;
var absoluteTempImagePath = Path.GetFullPath(tmpImgPath);
// Convert the base64 content to a byte array and save the bytes directly to a file
// this method should work for most use-cases
if (encoding.Equals("base64"))
{
System.IO.File.WriteAllBytes(absoluteTempImagePath, Convert.FromBase64String(imageData));
}
else
{
System.IO.File.WriteAllText(absoluteTempImagePath, HttpUtility.HtmlDecode(imageData), Encoding.UTF8);
}
// When the temp file has been created, we can persist it
PersistMediaItem(mediaParentFolder, userId, img, tmpImgPath);
}
return htmlDoc.DocumentNode.OuterHtml;
}
/// <summary>
/// Used by the RTE (and grid RTE) for drag/drop/persisting images.
/// </summary>
/// <param name="html">HTML from the Rich Text Editor property editor.</param>
/// <param name="mediaParentFolder"></param>
/// <param name="userId"></param>
/// <param name="imageUrlGenerator"></param>
/// <returns>Formatted HTML.</returns>
[Obsolete("It is not needed to supply the imageUrlGenerator parameter")]
public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator) =>
FindAndPersistPastedTempImages(html, mediaParentFolder, userId);
/// <summary>
/// Used by the RTE (and grid RTE) for drag/drop/persisting images.
/// </summary>
/// <param name="html">HTML from the Rich Text Editor property editor.</param>
/// <param name="mediaParentFolder"></param>
/// <param name="userId"></param>
/// <returns>Formatted HTML.</returns>
public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId)
{
// Find all img's that has data-tmpimg attribute
// Use HTML Agility Pack - https://html-agility-pack.net
@@ -69,16 +224,11 @@ public sealed class RichTextEditorPastedImages
htmlDoc.LoadHtml(html);
HtmlNodeCollection? tmpImages = htmlDoc.DocumentNode.SelectNodes($"//img[@{TemporaryImageDataAttribute}]");
if (tmpImages == null || tmpImages.Count == 0)
if (tmpImages is null || tmpImages.Count is 0)
{
return html;
}
// An array to contain a list of URLs that
// we have already processed to avoid dupes
var uploadedImages = new Dictionary<string, GuidUdi>();
foreach (HtmlNode? img in tmpImages)
{
// The data attribute contains the path to the tmp img to persist as a media item
@@ -89,116 +239,119 @@ public sealed class RichTextEditorPastedImages
continue;
}
var qualifiedTmpImgPath = _hostingEnvironment.MapPathContentRoot(tmpImgPath);
var absoluteTempImagePath = Path.GetFullPath(_hostingEnvironment.MapPathContentRoot(tmpImgPath));
if (IsValidPath(absoluteTempImagePath) == false)
{
continue;
}
var fileName = Path.GetFileName(absoluteTempImagePath);
var safeFileName = fileName.ToSafeFileName(_shortStringHelper);
var mediaItemName = safeFileName.ToFriendlyName();
IMedia mediaFile;
GuidUdi udi;
if (uploadedImages.ContainsKey(tmpImgPath) == false)
{
if (mediaParentFolder == Guid.Empty)
{
mediaFile = _mediaService.CreateMedia(mediaItemName, Constants.System.Root, Constants.Conventions.MediaTypes.Image, userId);
}
else
{
mediaFile = _mediaService.CreateMedia(mediaItemName, mediaParentFolder, Constants.Conventions.MediaTypes.Image, userId);
}
var fileInfo = new FileInfo(absoluteTempImagePath);
FileStream? fileStream = fileInfo.OpenReadWithRetry();
if (fileStream == null)
{
throw new InvalidOperationException("Could not acquire file stream");
}
using (fileStream)
{
mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream);
}
_mediaService.Save(mediaFile, userId);
udi = mediaFile.GetUdi();
}
else
{
// Already been uploaded & we have it's UDI
udi = uploadedImages[tmpImgPath];
}
// Add the UDI to the img element as new data attribute
img.SetAttributeValue("data-udi", udi.ToString());
// Get the new persisted image URL
_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext);
IPublishedContent? mediaTyped = umbracoContext?.Media?.GetById(udi.Guid);
if (mediaTyped == null)
{
throw new PanicException(
$"Could not find media by id {udi.Guid} or there was no UmbracoContext available.");
}
var location = mediaTyped.Url(_publishedUrlProvider);
// Find the width & height attributes as we need to set the imageprocessor QueryString
var width = img.GetAttributeValue("width", int.MinValue);
var height = img.GetAttributeValue("height", int.MinValue);
if (width != int.MinValue && height != int.MinValue)
{
location = imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(location)
{
ImageCropMode = ImageCropMode.Max,
Width = width,
Height = height,
});
}
img.SetAttributeValue("src", location);
// Remove the data attribute (so we do not re-process this)
img.Attributes.Remove(TemporaryImageDataAttribute);
// Add to the dictionary to avoid dupes
if (uploadedImages.ContainsKey(tmpImgPath) == false)
{
uploadedImages.Add(tmpImgPath, udi);
// Delete folder & image now its saved in media
// The folder should contain one image - as a unique guid folder created
// for each image uploaded from TinyMceController
var folderName = Path.GetDirectoryName(absoluteTempImagePath);
try
{
if (folderName is not null)
{
Directory.Delete(folderName, true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath);
}
}
PersistMediaItem(mediaParentFolder, userId, img, qualifiedTmpImgPath);
}
return htmlDoc.DocumentNode.OuterHtml;
}
private bool IsValidPath(string imagePath)
private void PersistMediaItem(Guid mediaParentFolder, int userId, HtmlNode img, string qualifiedTmpImgPath)
{
return imagePath.StartsWith(_tempFolderAbsolutePath);
var absoluteTempImagePath = Path.GetFullPath(qualifiedTmpImgPath);
if (IsValidPath(absoluteTempImagePath) is false)
{
return;
}
var fileName = Path.GetFileName(absoluteTempImagePath);
var safeFileName = fileName.ToSafeFileName(_shortStringHelper);
var mediaItemName = safeFileName.ToFriendlyName();
GuidUdi udi;
if (_uploadedImages.ContainsKey(qualifiedTmpImgPath) is false)
{
var isSvg = qualifiedTmpImgPath.EndsWith(".svg");
var mediaType = isSvg
? Constants.Conventions.MediaTypes.VectorGraphicsAlias
: Constants.Conventions.MediaTypes.Image;
IMedia mediaFile = mediaParentFolder == Guid.Empty
? _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaType, userId)
: _mediaService.CreateMedia(mediaItemName, mediaParentFolder, mediaType, userId);
var fileInfo = new FileInfo(absoluteTempImagePath);
FileStream? fileStream = fileInfo.OpenReadWithRetry();
if (fileStream is null)
{
throw new InvalidOperationException("Could not acquire file stream");
}
using (fileStream)
{
mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper,
_contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream);
}
_mediaService.Save(mediaFile, userId);
udi = mediaFile.GetUdi();
}
else
{
// Already been uploaded & we have it's UDI
udi = _uploadedImages[qualifiedTmpImgPath];
}
// Add the UDI to the img element as new data attribute
img.SetAttributeValue("data-udi", udi.ToString());
// Get the new persisted image URL
_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext);
IPublishedContent? mediaTyped = umbracoContext?.Media?.GetById(udi.Guid);
if (mediaTyped is null)
{
throw new PanicException(
$"Could not find media by id {udi.Guid} or there was no UmbracoContext available.");
}
var location = mediaTyped.Url(_publishedUrlProvider);
// Find the width & height attributes as we need to set the imageprocessor QueryString
var width = img.GetAttributeValue("width", int.MinValue);
var height = img.GetAttributeValue("height", int.MinValue);
if (width != int.MinValue && height != int.MinValue)
{
location = _imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(location)
{
ImageCropMode = ImageCropMode.Max,
Width = width,
Height = height,
});
}
img.SetAttributeValue("src", location);
// Remove the data attribute (so we do not re-process this)
img.Attributes.Remove(TemporaryImageDataAttribute);
// Add to the dictionary to avoid dupes
if (_uploadedImages.ContainsKey(qualifiedTmpImgPath) is false)
{
_uploadedImages.Add(qualifiedTmpImgPath, udi);
// Delete folder & image now its saved in media
// The folder should contain one image - as a unique guid folder created
// for each image uploaded from TinyMceController
var folderName = Path.GetDirectoryName(absoluteTempImagePath);
try
{
if (folderName is not null)
{
Directory.Delete(folderName, true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath);
}
}
}
private bool IsValidPath(string imagePath) => imagePath.StartsWith(_tempFolderAbsolutePath);
}

View File

@@ -293,8 +293,10 @@ public class RichTextPropertyEditor : DataEditor
return null;
}
var parseAndSaveBase64Images = _pastedImages.FindAndPersistEmbeddedImages(
editorValue.Value.ToString()!, mediaParentId, userId);
var parseAndSavedTempImages =
_pastedImages.FindAndPersistPastedTempImages(editorValue.Value.ToString()!, mediaParentId, userId, _imageUrlGenerator);
_pastedImages.FindAndPersistPastedTempImages(parseAndSaveBase64Images, mediaParentId, userId);
var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages);
var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved);
var sanitized = _htmlSanitizer.Sanitize(parsed);

View File

@@ -664,4 +664,87 @@ public class ContentControllerTests : UmbracoTestServerTestBase
Assert.AreEqual(0, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning));
});
}
[TestCase(
@"<p><img alt src=""data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7""></p>",
false)]
[TestCase(
@"<p><img src=""data:image/svg+xml;utf8,<svg viewBox=""0 0 70 74"" fill=""none"" xmlns=""http://www.w3.org/2000/svg""><rect width=""100%"" height=""100%"" fill=""black""/></svg>""></p>",
false)]
[TestCase(
@"<p><img alt src=""/some/random/image.jpg""></p><p><img alt src=""data:image/jpg;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7""></p>",
false)]
[TestCase(
@"<p><img alt src=""data:image/notallowedextension;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7""></p>",
true)]
public async Task PostSave_Simple_RichText_With_Base64(string html, bool shouldHaveDataUri)
{
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
var dataTypeService = GetRequiredService<IDataTypeService>();
var contentService = GetRequiredService<IContentService>();
var contentTypeService = GetRequiredService<IContentTypeService>();
var dataType = new DataTypeBuilder()
.WithId(0)
.WithoutIdentity()
.WithDatabaseType(ValueStorageType.Ntext)
.AddEditor()
.WithAlias(Constants.PropertyEditors.Aliases.TinyMce)
.Done()
.Build();
dataTypeService.Save(dataType);
var contentType = new ContentTypeBuilder()
.WithId(0)
.AddPropertyType()
.WithDataTypeId(dataType.Id)
.WithAlias("richText")
.WithName("Rich Text")
.Done()
.WithContentVariation(ContentVariation.Nothing)
.Build();
contentTypeService.Save(contentType);
var content = new ContentBuilder()
.WithId(0)
.WithName("Invariant")
.WithContentType(contentType)
.AddPropertyData()
.WithKeyValue("richText", html)
.Done()
.Build();
contentService.SaveAndPublish(content);
var model = new ContentItemSaveBuilder()
.WithContent(content)
.Build();
// Act
var response =
await Client.PostAsync(url, new MultipartFormDataContent {{new StringContent(JsonConvert.SerializeObject(model)), "contentItem"}});
// Assert
var body = await response.Content.ReadAsStringAsync();
body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix);
Assert.Multiple(() =>
{
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body);
var display = JsonConvert.DeserializeObject<ContentItemDisplay>(body);
var bodyText = display.Variants.FirstOrDefault()?.Tabs.FirstOrDefault()?.Properties
?.FirstOrDefault(x => x.Alias.Equals("richText"))?.Value?.ToString();
Assert.NotNull(bodyText);
var containsDataUri = bodyText.Contains("data:image");
if (shouldHaveDataUri)
{
Assert.True(containsDataUri, $"Data URIs were expected to be found in the body: {bodyText}");
} else {
Assert.False(containsDataUri, $"Data URIs were not expected to be found in the body: {bodyText}");
}
});
}
}

View File

@@ -1,4 +1,5 @@
{
"$schema": "./appsettings-schema.json",
"Logging": {
"LogLevel": {
"Default": "Warning",
@@ -16,5 +17,12 @@
"EmptyDatabasesCount": 2,
"SQLServerMasterConnectionString": ""
}
},
"Umbraco": {
"CMS": {
"Content": {
"AllowedUploadedFileExtensions": ["jpg", "png", "gif", "svg"]
}
}
}
}