using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using LightInject; using Umbraco.Core.Configuration; using Umbraco.Core.Configuration.UmbracoSettings; using Umbraco.Core.DI; using Umbraco.Core.Exceptions; using Umbraco.Core.Logging; using Umbraco.Core.Media; using Umbraco.Core.Media.Exif; using Umbraco.Core.Models; using Umbraco.Core.Services; namespace Umbraco.Core.IO { /// /// A custom file system provider for media /// [FileSystemProvider("media")] public class MediaFileSystem : FileSystemWrapper { private readonly object _folderCounterLock = new object(); private long _folderCounter; private bool _folderCounterInitialized; private static readonly Dictionary DefaultSizes = new Dictionary { { 100, "thumb" }, { 500, "big-thumb" } }; public MediaFileSystem(IFileSystem wrapped) : base(wrapped) { // due to how FileSystems is written at the moment, the ctor cannot be used to inject // dependencies, so we have to rely on property injection for anything we might need Current.Container.InjectProperties(this); UploadAutoFillProperties = new UploadAutoFillProperties(this, Logger, ContentConfig); } [Inject] internal IContentSection ContentConfig { get; set; } [Inject] internal ILogger Logger { get; set; } [Inject] internal IDataTypeService DataTypeService { get; set; } internal UploadAutoFillProperties UploadAutoFillProperties { get; } // note - this is currently experimental / being developed //public static bool UseTheNewMediaPathScheme { get; set; } public const bool UseTheNewMediaPathScheme = false; /// /// Deletes all files passed in. /// /// /// /// internal bool DeleteFiles(IEnumerable files, Action onError = null) { //ensure duplicates are removed files = files.Distinct(); var allsuccess = true; var rootRelativePath = GetRelativePath("/"); Parallel.ForEach(files, file => { try { if (file.IsNullOrWhiteSpace()) return; var relativeFilePath = GetRelativePath(file); if (FileExists(relativeFilePath) == false) return; var parentDirectory = Path.GetDirectoryName(relativeFilePath); // don't want to delete the media folder if not using directories. if (ContentConfig.UploadAllowDirectories && parentDirectory != rootRelativePath) { //issue U4-771: if there is a parent directory the recursive parameter should be true DeleteDirectory(parentDirectory, string.IsNullOrEmpty(parentDirectory) == false); } else { DeleteFile(file, true); } } catch (Exception e) { onError?.Invoke(file, e); allsuccess = false; } }); return allsuccess; } #region Media Path /// /// Gets the file path of a media file. /// /// The file name. /// The unique identifier of the content/media owning the file. /// The unique identifier of the property type owning the file. /// The filesystem-relative path to the media file. /// With the old media path scheme, this CREATES a new media path each time it is invoked. public string GetMediaPath(string filename, Guid cuid, Guid puid) { filename = Path.GetFileName(filename); if (filename == null) throw new ArgumentException("Cannot become a safe filename.", nameof(filename)); filename = IOHelper.SafeFileName(filename.ToLowerInvariant()); string folder; if (UseTheNewMediaPathScheme == false) { // old scheme: filepath is "/" OR "-" // default media filesystem maps to "~/media/" folder = GetNextFolder(); } else { // new scheme: path is "-/" OR "--" // default media filesystem maps to "~/media/" // assumes that cuid and puid keys can be trusted - and that a single property type // for a single content cannot store two different files with the same name folder = Combine(cuid, puid).ToHexString(/*'/', 2, 4*/); // could use ext to fragment path eg 12/e4/f2/... } var filepath = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories ? Path.Combine(folder, filename) : folder + "-" + filename; return filepath; } private static byte[] Combine(Guid guid1, Guid guid2) { var bytes1 = guid1.ToByteArray(); var bytes2 = guid2.ToByteArray(); var bytes = new byte[bytes1.Length]; for (var i = 0; i < bytes1.Length; i++) bytes[i] = (byte) (bytes1[i] ^ bytes2[i]); return bytes; } /// /// Gets the file path of a media file. /// /// The file name. /// A previous file path. /// The unique identifier of the content/media owning the file. /// The unique identifier of the property type owning the file. /// The filesystem-relative path to the media file. /// In the old, legacy, number-based scheme, we try to re-use the media folder /// specified by . Else, we CREATE a new one. Each time we are invoked. public string GetMediaPath(string filename, string prevpath, Guid cuid, Guid puid) { if (UseTheNewMediaPathScheme || string.IsNullOrWhiteSpace(prevpath)) return GetMediaPath(filename, cuid, puid); filename = Path.GetFileName(filename); if (filename == null) throw new ArgumentException("Cannot become a safe filename.", nameof(filename)); filename = IOHelper.SafeFileName(filename.ToLowerInvariant()); // old scheme, with a previous path // prevpath should be "/" OR "-" // and we want to reuse the "" part, so try to find it var sep = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories ? "/" : "-"; var pos = prevpath.IndexOf(sep, StringComparison.Ordinal); var s = pos > 0 ? prevpath.Substring(0, pos) : null; int ignored; var folder = (pos > 0 && int.TryParse(s, out ignored)) ? s : GetNextFolder(); // ReSharper disable once AssignNullToNotNullAttribute var filepath = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories ? Path.Combine(folder, filename) : folder + "-" + filename; return filepath; } /// /// Gets the next media folder in the original number-based scheme. /// /// /// Should be private, is internal for legacy FileHandlerData which is obsolete. internal string GetNextFolder() { EnsureFolderCounterIsInitialized(); return Interlocked.Increment(ref _folderCounter).ToString(CultureInfo.InvariantCulture); } private void EnsureFolderCounterIsInitialized() { lock (_folderCounterLock) { if (_folderCounterInitialized) return; _folderCounter = 1000; // seed var directories = GetDirectories(""); foreach (var directory in directories) { long folderNumber; if (long.TryParse(directory, out folderNumber) && folderNumber > _folderCounter) _folderCounter = folderNumber; } // note: not multi-domains ie LB safe as another domain could create directories // while we read and parse them - don't fix, move to new scheme eventually _folderCounterInitialized = true; } } #endregion #region Associated Media Files /// /// Stores a media file associated to a property of a content item. /// /// The content item owning the media file. /// The property type owning the media file. /// The media file name. /// A stream containing the media bytes. /// An optional filesystem-relative filepath to the previous media file. /// The filesystem-relative filepath to the media file. /// /// The file is considered "owned" by the content/propertyType. /// If an is provided then that file (and associated thumbnails if any) is deleted /// before the new file is saved, and depending on the media path scheme, the folder may be reused for the new file. /// public string StoreFile(IContentBase content, PropertyType propertyType, string filename, Stream filestream, string oldpath) { if (content == null) throw new ArgumentNullException(nameof(content)); if (propertyType == null) throw new ArgumentNullException(nameof(propertyType)); if (string.IsNullOrWhiteSpace(filename)) throw new ArgumentNullOrEmptyException(nameof(filename)); if (filestream == null) throw new ArgumentNullException(nameof(filestream)); // clear the old file, if any if (string.IsNullOrWhiteSpace(oldpath) == false) DeleteFile(oldpath, true); // get the filepath, store the data // use oldpath as "prevpath" to try and reuse the folder, in original number-based scheme var filepath = GetMediaPath(filename, oldpath, content.Key, propertyType.Key); AddFile(filepath, filestream); return filepath; } /// /// Clears a media file. /// /// The filesystem-relative path to the media file. public new void DeleteFile(string filepath) { DeleteFile(filepath, true); } /// /// Copies a media file as a new media file, associated to a property of a content item. /// /// The content item owning the copy of the media file. /// The property type owning the copy of the media file. /// The filesystem-relative path to the source media file. /// The filesystem-relative path to the copy of the media file. public string CopyFile(IContentBase content, PropertyType propertyType, string sourcepath) { if (content == null) throw new ArgumentNullException(nameof(content)); if (propertyType == null) throw new ArgumentNullException(nameof(propertyType)); if (string.IsNullOrWhiteSpace(sourcepath)) throw new ArgumentNullOrEmptyException(nameof(sourcepath)); // ensure we have a file to copy if (FileExists(sourcepath) == false) return null; // get the filepath var filename = Path.GetFileName(sourcepath); var filepath = GetMediaPath(filename, content.Key, propertyType.Key); this.CopyFile(sourcepath, filepath); CopyThumbnails(sourcepath, filepath); return filepath; } // gets or creates a property for a content item. private static Property GetProperty(IContentBase content, string propertyTypeAlias) { var property = content.Properties.FirstOrDefault(x => x.Alias.InvariantEquals(propertyTypeAlias)); if (property != null) return property; var propertyType = content.GetContentType().CompositionPropertyTypes .FirstOrDefault(x => x.Alias.InvariantEquals(propertyTypeAlias)); if (propertyType == null) throw new Exception("No property type exists with alias " + propertyTypeAlias + "."); property = new Property(propertyType); content.Properties.Add(property); return property; } // fixme - what's below belongs to the upload property editor, not the media filesystem! public void SetUploadFile(IContentBase content, string propertyTypeAlias, string filename, Stream filestream) { var property = GetProperty(content, propertyTypeAlias); var svalue = property.Value as string; var oldpath = svalue == null ? null : GetRelativePath(svalue); var filepath = StoreFile(content, property.PropertyType, filename, filestream, oldpath); property.Value = GetUrl(filepath); SetUploadFile(content, property, filepath, filestream); } public void SetUploadFile(IContentBase content, string propertyTypeAlias, string filepath) { var property = GetProperty(content, propertyTypeAlias); var svalue = property.Value as string; var oldpath = svalue == null ? null : GetRelativePath(svalue); // FIXME DELETE? if (string.IsNullOrWhiteSpace(oldpath) == false && oldpath != filepath) DeleteFile(oldpath, true); property.Value = GetUrl(filepath); using (var filestream = OpenFile(filepath)) { SetUploadFile(content, property, filepath, filestream); } } // sets a file for the FileUpload property editor // ie generates thumbnails and populates autofill properties private void SetUploadFile(IContentBase content, Property property, string filepath, Stream filestream) { // check if file is an image (and supports resizing and thumbnails etc) var extension = Path.GetExtension(filepath); var isImage = IsImageFile(extension); // specific stuff for images (thumbnails etc) if (isImage) { using (var image = Image.FromStream(filestream)) { // use one image for all GenerateThumbnails(image, filepath, property.PropertyType); UploadAutoFillProperties.Populate(content, property.Alias, filepath, filestream, image); } } else { // will use filepath for extension, and filestream for length UploadAutoFillProperties.Populate(content, property.Alias, filepath, filestream); } } #endregion #region Image /// /// Gets a value indicating whether the file extension corresponds to an image. /// /// The file extension. /// A value indicating whether the file extension corresponds to an image. public bool IsImageFile(string extension) { if (extension == null) return false; extension = extension.TrimStart('.'); return ContentConfig.ImageFileTypes.InvariantContains(extension); } /// /// Gets the dimensions of an image. /// /// A stream containing the image bytes. /// The dimension of the image. /// First try with EXIF as it is faster and does not load the entire image /// in memory. Fallback to GDI which means loading the image in memory and thus /// use potentially large amounts of memory. public Size GetDimensions(Stream stream) { //Try to load with exif try { var jpgInfo = ImageFile.FromStream(stream); if (jpgInfo.Format != ImageFileFormat.Unknown && jpgInfo.Properties.ContainsKey(ExifTag.PixelYDimension) && jpgInfo.Properties.ContainsKey(ExifTag.PixelXDimension)) { var height = Convert.ToInt32(jpgInfo.Properties[ExifTag.PixelYDimension].Value); var width = Convert.ToInt32(jpgInfo.Properties[ExifTag.PixelXDimension].Value); if (height > 0 && width > 0) { return new Size(width, height); } } } catch (Exception) { //We will just swallow, just means we can't read exif data, we don't want to log an error either } //we have no choice but to try to read in via GDI using (var image = Image.FromStream(stream)) { var fileWidth = image.Width; var fileHeight = image.Height; return new Size(fileWidth, fileHeight); } } #endregion #region Manage thumbnails // note: this does not find 'custom' thumbnails? // will find _thumb and _big-thumb but NOT _custom? public IEnumerable GetThumbnails(string path) { var parentDirectory = Path.GetDirectoryName(path); var extension = Path.GetExtension(path); return GetFiles(parentDirectory) .Where(x => x.StartsWith(path.TrimEnd(extension) + "_thumb") || x.StartsWith(path.TrimEnd(extension) + "_big-thumb")) .ToList(); } public void DeleteFile(string path, bool deleteThumbnails) { base.DeleteFile(path); if (deleteThumbnails == false) return; DeleteThumbnails(path); } public void DeleteThumbnails(string path) { GetThumbnails(path) .ForEach(x => base.DeleteFile(x)); } public void CopyThumbnails(string sourcePath, string targetPath) { var targetPathBase = Path.GetDirectoryName(targetPath) ?? ""; foreach (var sourceThumbPath in GetThumbnails(sourcePath)) { var sourceThumbFilename = Path.GetFileName(sourceThumbPath) ?? ""; var targetThumbPath = Path.Combine(targetPathBase, sourceThumbFilename); this.CopyFile(sourceThumbPath, targetThumbPath); } } #endregion #region GenerateThumbnails public IEnumerable GenerateThumbnails(Image image, string filepath, string preValue) { if (string.IsNullOrWhiteSpace(preValue)) return GenerateThumbnails(image, filepath); var additionalSizes = new List(); var sep = preValue.Contains(",") ? "," : ";"; var values = preValue.Split(new[] { sep }, StringSplitOptions.RemoveEmptyEntries); foreach (var value in values) { int size; if (int.TryParse(value, out size)) additionalSizes.Add(size); } return GenerateThumbnails(image, filepath, additionalSizes); } public IEnumerable GenerateThumbnails(Image image, string filepath, IEnumerable additionalSizes = null) { var w = image.Width; var h = image.Height; var sizes = additionalSizes == null ? DefaultSizes.Keys : DefaultSizes.Keys.Concat(additionalSizes); // start with default sizes, // add additional sizes, // filter out duplicates, // filter out those that would be larger that the original image // and create the thumbnail return sizes .Distinct() .Where(x => w >= x && h >= x) .Select(x => GenerateResized(image, filepath, DefaultSizes.ContainsKey(x) ? DefaultSizes[x] : "", x)) .ToList(); // now } public IEnumerable GenerateThumbnails(Stream filestream, string filepath, PropertyType propertyType) { // get the original image from the original stream if (filestream.CanSeek) filestream.Seek(0, 0); // fixme - what if we cannot seek? using (var image = Image.FromStream(filestream)) { return GenerateThumbnails(image, filepath, propertyType); } } public IEnumerable GenerateThumbnails(Image image, string filepath, PropertyType propertyType) { // if the editor is an upload field, check for additional thumbnail sizes // that can be defined in the prevalue for the property data type. otherwise, // just use the default sizes. var sizes = propertyType.PropertyEditorAlias == Constants.PropertyEditors.UploadFieldAlias ? DataTypeService .GetPreValuesByDataTypeId(propertyType.DataTypeDefinitionId) .FirstOrDefault() : string.Empty; return GenerateThumbnails(image, filepath, sizes); } #endregion #region GenerateResized - Generate at resized filepath derived from origin filepath public ResizedImage GenerateResized(Image originImage, string originFilepath, string sizeName, int maxWidthHeight) { return GenerateResized(originImage, originFilepath, sizeName, maxWidthHeight, -1, -1); } public ResizedImage GenerateResized(Image originImage, string originFilepath, string sizeName, int fixedWidth, int fixedHeight) { return GenerateResized(originImage, originFilepath, sizeName, -1, fixedWidth, fixedHeight); } public ResizedImage GenerateResized(Image originImage, string originFilepath, string sizeName, int maxWidthHeight, int fixedWidth, int fixedHeight) { if (string.IsNullOrWhiteSpace(sizeName)) sizeName = "UMBRACOSYSTHUMBNAIL"; var extension = Path.GetExtension(originFilepath) ?? string.Empty; var filebase = originFilepath.TrimEnd(extension); var resizedFilepath = filebase + "_" + sizeName + extension; return GenerateResizedAt(originImage, resizedFilepath, maxWidthHeight, fixedWidth, fixedHeight); } #endregion #region GenerateResizedAt - Generate at specified resized filepath public ResizedImage GenerateResizedAt(Image originImage, string resizedFilepath, int maxWidthHeight) { return GenerateResizedAt(originImage, resizedFilepath, maxWidthHeight, -1, -1); } public ResizedImage GenerateResizedAt(Image originImage, int fixedWidth, int fixedHeight, string resizedFilepath) { return GenerateResizedAt(originImage, resizedFilepath, -1, fixedWidth, fixedHeight); } public ResizedImage GenerateResizedAt(Image originImage, string resizedFilepath, int maxWidthHeight, int fixedWidth, int fixedHeight) { // target dimensions int width, height; // if maxWidthHeight then get ratio if (maxWidthHeight > 0) { var fx = (float)originImage.Size.Width / maxWidthHeight; var fy = (float)originImage.Size.Height / maxWidthHeight; var f = Math.Max(fx, fy); // fit in thumbnail size width = (int)Math.Round(originImage.Size.Width / f); height = (int)Math.Round(originImage.Size.Height / f); if (width == 0) width = 1; if (height == 0) height = 1; } else if (fixedWidth > 0 && fixedHeight > 0) { width = fixedWidth; height = fixedHeight; } else { width = height = 1; } // create new image with best quality settings using (var bitmap = new Bitmap(width, height)) using (var graphics = Graphics.FromImage(bitmap)) { // if the image size is rather large we cannot use the best quality interpolation mode // because we'll get out of mem exceptions. So we detect how big the image is and use // the mid quality interpolation mode when the image size exceeds our max limit. graphics.InterpolationMode = originImage.Width > 5000 || originImage.Height > 5000 ? InterpolationMode.Bilinear // mid quality : InterpolationMode.HighQualityBicubic; // best quality // everything else is best-quality graphics.SmoothingMode = SmoothingMode.HighQuality; graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; graphics.CompositingQuality = CompositingQuality.HighQuality; // copy the old image to the new and resize var rect = new Rectangle(0, 0, width, height); graphics.DrawImage(originImage, rect, 0, 0, originImage.Width, originImage.Height, GraphicsUnit.Pixel); // copy metadata // fixme - er... no? // get an encoder - based upon the file type var extension = (Path.GetExtension(resizedFilepath) ?? "").TrimStart('.').ToLowerInvariant(); var encoders = ImageCodecInfo.GetImageEncoders(); ImageCodecInfo encoder; switch (extension) { case "png": encoder = encoders.Single(t => t.MimeType.Equals("image/png")); break; case "gif": encoder = encoders.Single(t => t.MimeType.Equals("image/gif")); break; case "tif": case "tiff": encoder = encoders.Single(t => t.MimeType.Equals("image/tiff")); break; case "bmp": encoder = encoders.Single(t => t.MimeType.Equals("image/bmp")); break; // TODO: this is dirty, defaulting to jpg but the return value of this thing is used all over the // place so left it here, but it needs to not set a codec if it doesn't know which one to pick // Note: when fixing this: both .jpg and .jpeg should be handled as extensions default: encoder = encoders.Single(t => t.MimeType.Equals("image/jpeg")); break; } // set compresion ratio to 90% var encoderParams = new EncoderParameters(); encoderParams.Param[0] = new EncoderParameter(Encoder.Quality, 90L); // save the new image using (var stream = new MemoryStream()) { bitmap.Save(stream, encoder, encoderParams); stream.Seek(0, 0); if (resizedFilepath.Contains("UMBRACOSYSTHUMBNAIL")) { var filepath = resizedFilepath.Replace("UMBRACOSYSTHUMBNAIL", maxWidthHeight.ToInvariantString()); AddFile(filepath, stream); if (extension != "jpg") { filepath = filepath.TrimEnd(extension) + "jpg"; stream.Seek(0, 0); AddFile(filepath, stream); } // TODO: Remove this, this is ONLY here for backwards compatibility but it is essentially completely unusable see U4-5385 stream.Seek(0, 0); resizedFilepath = resizedFilepath.Replace("UMBRACOSYSTHUMBNAIL", width + "x" + height); } AddFile(resizedFilepath, stream); } return new ResizedImage(resizedFilepath, width, height); } } #endregion #region Inner classes public class ResizedImage { public ResizedImage() { } public ResizedImage(string filepath, int width, int height) { Filepath = filepath; Width = width; Height = height; } public string Filepath { get; set; } public int Width { get; set; } public int Height { get; set; } } #endregion } }