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.Composing; 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; // fixme - remove //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); } } catch (Exception e) { onError?.Invoke(file, e); allsuccess = false; } }); return allsuccess; } public void DeleteMediaFiles(IEnumerable files) { files = files.Distinct(); Parallel.ForEach(files, file => { try { if (file.IsNullOrWhiteSpace()) return; if (FileExists(file) == false) return; DeleteFile(file); if (UseTheNewMediaPathScheme == false) { // old scheme: filepath is "/" OR "-" // remove the directory if any var dir = Path.GetDirectoryName(file); if (string.IsNullOrWhiteSpace(dir) == false) DeleteDirectory(dir, true); } else { // new scheme: path is "/" where xuid is a combination of cuid and puid // remove the directory var dir = Path.GetDirectoryName(file); DeleteDirectory(dir, true); } } catch (Exception e) { Logger.Error("Failed to delete attached file \"" + file + "\".", e); } }); } #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 "/" where xuid is a combination of cuid and puid // 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).Replace('\\', '/') : 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); // 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; } /// /// 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); 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); 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) { // 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 // fixme - remove //#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 } }