diff --git a/Oqtane.Server/Controllers/FileController.cs b/Oqtane.Server/Controllers/FileController.cs index 5aa8a66e..1e8a1740 100644 --- a/Oqtane.Server/Controllers/FileController.cs +++ b/Oqtane.Server/Controllers/FileController.cs @@ -17,11 +17,10 @@ using Oqtane.Infrastructure; using Oqtane.Repository; using Oqtane.Extensions; using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Png; using System.Net.Http; using Microsoft.AspNetCore.Cors; using System.IO.Compression; +using Oqtane.Services; // ReSharper disable StringIndexOfIsCultureSpecific.1 @@ -38,7 +37,9 @@ namespace Oqtane.Controllers private readonly ILogManager _logger; private readonly Alias _alias; private readonly ISettingRepository _settingRepository; - public FileController(IWebHostEnvironment environment, IFileRepository files, IFolderRepository folders, IUserPermissions userPermissions, ISettingRepository settingRepository, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager) + private readonly IImageService _imageService; + + public FileController(IWebHostEnvironment environment, IFileRepository files, IFolderRepository folders, IUserPermissions userPermissions, ISettingRepository settingRepository, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager, IImageService imageService) { _environment = environment; _files = files; @@ -48,6 +49,7 @@ namespace Oqtane.Controllers _logger = logger; _alias = tenantManager.GetAlias(); _settingRepository = settingRepository; + _imageService = imageService; } // GET: api/?folder=x @@ -681,22 +683,18 @@ namespace Oqtane.Controllers var filepath = _files.GetFilePath(file); if (System.IO.File.Exists(filepath)) { - // validation - if (!Enum.TryParse(mode, true, out ResizeMode _)) mode = "crop"; - if (!Enum.TryParse(position, true, out AnchorPositionMode _)) position = "center"; - if (!Color.TryParseHex("#" + background, out _)) background = "transparent"; - if (!int.TryParse(rotate, out _)) rotate = "0"; - rotate = (int.Parse(rotate) < 0 || int.Parse(rotate) > 360) ? "0" : rotate; if (!bool.TryParse(recreate, out _)) recreate = "false"; - string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + ".png"); + string format = "png"; + + string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + format); if (!System.IO.File.Exists(imagepath) || bool.Parse(recreate)) { // user has edit access to folder or folder supports the image size being created if (_userPermissions.IsAuthorized(User, PermissionNames.Edit, file.Folder.PermissionList) || (!string.IsNullOrEmpty(file.Folder.ImageSizes) && (file.Folder.ImageSizes == "*" || file.Folder.ImageSizes.ToLower().Split(",").Contains(width.ToString() + "x" + height.ToString())))) { - imagepath = CreateImage(filepath, width, height, mode, position, background, rotate, imagepath); + imagepath = _imageService.CreateImage(filepath, width, height, mode, position, background, rotate, format, imagepath); } else { @@ -743,70 +741,6 @@ namespace Oqtane.Controllers return System.IO.File.Exists(errorPath) ? PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)) : null; } - private string CreateImage(string filepath, int width, int height, string mode, string position, string background, string rotate, string imagepath) - { - try - { - using (var stream = new FileStream(filepath, FileMode.Open, FileAccess.Read)) - { - stream.Position = 0; - using (var image = Image.Load(stream)) - { - int.TryParse(rotate, out int angle); - Enum.TryParse(mode, true, out ResizeMode resizemode); - Enum.TryParse(position, true, out AnchorPositionMode anchorpositionmode); - - PngEncoder encoder; - - if (background != "transparent") - { - image.Mutate(x => x - .AutoOrient() // auto orient the image - .Rotate(angle) - .Resize(new ResizeOptions - { - Mode = resizemode, - Position = anchorpositionmode, - Size = new Size(width, height), - PadColor = Color.ParseHex("#" + background) - })); - - encoder = new PngEncoder(); - } - else - { - image.Mutate(x => x - .AutoOrient() // auto orient the image - .Rotate(angle) - .Resize(new ResizeOptions - { - Mode = resizemode, - Position = anchorpositionmode, - Size = new Size(width, height) - })); - - encoder = new PngEncoder - { - ColorType = PngColorType.RgbWithAlpha, - TransparentColorMode = PngTransparentColorMode.Preserve, - BitDepth = PngBitDepth.Bit8, - CompressionLevel = PngCompressionLevel.BestSpeed - }; - } - - image.Save(imagepath, encoder); - } - } - } - catch (Exception ex) - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, ex, "Error Creating Image For File {FilePath} {Width} {Height} {Mode} {Rotate} {Error}", filepath, width, height, mode, rotate, ex.Message); - imagepath = ""; - } - - return imagepath; - } - private string GetFolderPath(string folder) { return Utilities.PathCombine(_environment.ContentRootPath, folder); diff --git a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs index 274a0aab..2b03eec6 100644 --- a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs @@ -102,6 +102,7 @@ namespace Microsoft.Extensions.DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // providers services.AddScoped(); diff --git a/Oqtane.Server/Pages/Files.cshtml.cs b/Oqtane.Server/Pages/Files.cshtml.cs index d8cee171..463534fc 100644 --- a/Oqtane.Server/Pages/Files.cshtml.cs +++ b/Oqtane.Server/Pages/Files.cshtml.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; @@ -14,6 +15,7 @@ using Oqtane.Infrastructure; using Oqtane.Models; using Oqtane.Repository; using Oqtane.Security; +using Oqtane.Services; using Oqtane.Shared; namespace Oqtane.Pages @@ -28,8 +30,10 @@ namespace Oqtane.Pages private readonly ISyncManager _syncManager; private readonly ILogManager _logger; private readonly Alias _alias; + private readonly IImageService _imageService; + private readonly ISettingRepository _settingRepository; - public FilesModel(IWebHostEnvironment environment, IFileRepository files, IUserPermissions userPermissions, IUrlMappingRepository urlMappings, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager) + public FilesModel(IWebHostEnvironment environment, IFileRepository files, IUserPermissions userPermissions, IUrlMappingRepository urlMappings, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager, IImageService imageService, ISettingRepository settingRepository) { _environment = environment; _files = files; @@ -38,111 +42,228 @@ namespace Oqtane.Pages _syncManager = syncManager; _logger = logger; _alias = tenantManager.GetAlias(); + _imageService = imageService; + _settingRepository = settingRepository; } public IActionResult OnGet(string path) { - if (!string.IsNullOrEmpty(path)) - { - path = path.Replace("\\", "/"); - var folderpath = ""; - var filename = ""; - - bool download = false; - if (Request.Query.ContainsKey("download")) - { - download = true; - } - - var segments = path.Split('/'); - if (segments.Length > 0) - { - filename = segments[segments.Length - 1].ToLower(); - if (segments.Length > 1) - { - folderpath = string.Join("/", segments, 0, segments.Length - 1).ToLower() + "/"; - } - } - - Models.File file; - if (folderpath == "id/" && int.TryParse(filename, out int fileid)) - { - file = _files.GetFile(fileid, false); - } - else - { - file = _files.GetFile(_alias.SiteId, folderpath, filename); - } - - if (file != null) - { - if (file.Folder.SiteId == _alias.SiteId && _userPermissions.IsAuthorized(User, PermissionNames.View, file.Folder.PermissionList)) - { - // calculate ETag using last modified date and file size - var etag = Convert.ToString(file.ModifiedOn.Ticks ^ file.Size, 16); - - var header = ""; - if (HttpContext.Request.Headers.ContainsKey(HeaderNames.IfNoneMatch)) - { - header = HttpContext.Request.Headers[HeaderNames.IfNoneMatch].ToString(); - } - - if (!header.Equals(etag)) - { - var filepath = _files.GetFilePath(file); - if (System.IO.File.Exists(filepath)) - { - if (download) - { - _syncManager.AddSyncEvent(_alias, EntityNames.File, file.FileId, "Download"); - return PhysicalFile(filepath, file.GetMimeType(), file.Name); - } - else - { - HttpContext.Response.Headers.Append(HeaderNames.ETag, etag); - return PhysicalFile(filepath, file.GetMimeType()); - } - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Read, "File Does Not Exist {FilePath}", filepath); - HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; - } - } - else - { - HttpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; - return Content(String.Empty); - } - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Access Attempt For Site {SiteId} And Path {Path}", _alias.SiteId, path); - HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; - } - } - else - { - // look for url mapping - var urlMapping = _urlMappings.GetUrlMapping(_alias.SiteId, "files/" + folderpath + filename); - if (urlMapping != null && !string.IsNullOrEmpty(urlMapping.MappedUrl)) - { - var url = urlMapping.MappedUrl; - if (!url.StartsWith("http")) - { - var uri = new Uri(HttpContext.Request.GetEncodedUrl()); - url = uri.Scheme + "://" + uri.Authority + ((!string.IsNullOrEmpty(_alias.Path)) ? "/" + _alias.Path : "") + "/" + url; - } - return RedirectPermanent(url); - } - } - } - else + if (string.IsNullOrWhiteSpace(path)) { _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Access Attempt - Path Not Specified For Site {SiteId}", _alias.SiteId); HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); } + path = path.Replace("\\", "/"); + var folderpath = ""; + var filename = ""; + + bool download = false; + if (Request.Query.ContainsKey("download")) + { + download = true; + } + + var segments = path.Split('/'); + if (segments.Length > 0) + { + filename = segments[segments.Length - 1].ToLower(); + if (segments.Length > 1) + { + folderpath = string.Join("/", segments, 0, segments.Length - 1).ToLower() + "/"; + } + } + + Models.File file; + if (folderpath == "id/" && int.TryParse(filename, out int fileid)) + { + file = _files.GetFile(fileid, false); + } + else + { + file = _files.GetFile(_alias.SiteId, folderpath, filename); + } + + if (file == null) + { + // look for url mapping + + var urlMapping = _urlMappings.GetUrlMapping(_alias.SiteId, "files/" + folderpath + filename); + if (urlMapping != null && !string.IsNullOrEmpty(urlMapping.MappedUrl)) + { + var url = urlMapping.MappedUrl; + if (!url.StartsWith("http")) + { + var uri = new Uri(HttpContext.Request.GetEncodedUrl()); + url = uri.Scheme + "://" + uri.Authority + ((!string.IsNullOrEmpty(_alias.Path)) ? "/" + _alias.Path : "") + "/" + url; + } + + // appends the query string to the redirect url + if (Request.QueryString.HasValue && !string.IsNullOrWhiteSpace(Request.QueryString.Value)) + { + if (url.Contains('?')) + { + url += "&"; + } + else + { + url += "?"; + } + + url += Request.QueryString.Value.Substring(1); + } + + return RedirectPermanent(url); + } + + return BrokenFile(); + } + + if (file.Folder.SiteId != _alias.SiteId || !_userPermissions.IsAuthorized(User, PermissionNames.View, file.Folder.PermissionList)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Access Attempt For Site {SiteId} And Path {Path}", _alias.SiteId, path); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); + } + + string etag; + string downloadName = file.Name; + string filepath = _files.GetFilePath(file); + + var etagValue = file.ModifiedOn.Ticks ^ file.Size; + + bool isRequestingImageManipulation = false; + + int width = 0; + int height = 0; + if (Request.Query.TryGetValue("width", out var widthStr) && int.TryParse(widthStr, out width) && width > 0) + { + isRequestingImageManipulation = true; + etagValue ^= (width * 31); + } + if (Request.Query.TryGetValue("height", out var heightStr) && int.TryParse(heightStr, out height) && height > 0) + { + isRequestingImageManipulation = true; + etagValue ^= (height * 17); + } + + Request.Query.TryGetValue("mode", out var mode); + Request.Query.TryGetValue("position", out var position); + Request.Query.TryGetValue("background", out var background); + + if (width > 0 || height > 0) + { + if (!string.IsNullOrWhiteSpace(mode)) etagValue ^= mode.ToString().GetHashCode(); + if (!string.IsNullOrWhiteSpace(position)) etagValue ^= position.ToString().GetHashCode(); + if (!string.IsNullOrWhiteSpace(background)) etagValue ^= background.ToString().GetHashCode(); + } + + int rotate; + if (Request.Query.TryGetValue("rotate", out var rotateStr) && int.TryParse(rotateStr, out rotate) && 360 > rotate && rotate > 0) + { + isRequestingImageManipulation = true; + etagValue ^= (rotate * 13); + } + + if (Request.Query.TryGetValue("format", out var format) && _imageService.GetAvailableFormats().Contains(format.ToString())) + { + isRequestingImageManipulation = true; + etagValue ^= format.ToString().GetHashCode(); + } + + etag = Convert.ToString(etagValue, 16); + + var header = ""; + if (HttpContext.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var ifNoneMatch)) + { + header = ifNoneMatch.ToString(); + } + + if (header.Equals(etag)) + { + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; + return Content(String.Empty); + } + + if (!System.IO.File.Exists(filepath)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Read, "File Does Not Exist {FilePath}", filepath); + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return BrokenFile(); + } + + if (isRequestingImageManipulation) + { + var _ImageFiles = _settingRepository.GetSetting(EntityNames.Site, _alias.SiteId, "ImageFiles")?.SettingValue; + _ImageFiles = (string.IsNullOrEmpty(_ImageFiles)) ? Constants.ImageFiles : _ImageFiles; + + if (!_ImageFiles.Split(',').Contains(file.Extension.ToLower())) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "File Is Not An Image {File}", file); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); + } + + Request.Query.TryGetValue("recreate", out var recreate); + + if (!bool.TryParse(recreate, out _)) recreate = "false"; + if (!_imageService.GetAvailableFormats().Contains(format.ToString())) format = "png"; + if (width == 0 && height == 0) + { + width = file.ImageWidth; + height = file.ImageHeight; + } + + string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + format); + if (!System.IO.File.Exists(imagepath) || bool.Parse(recreate)) + { + // user has edit access to folder or folder supports the image size being created + if (_userPermissions.IsAuthorized(User, PermissionNames.Edit, file.Folder.PermissionList) || + (!string.IsNullOrEmpty(file.Folder.ImageSizes) && (file.Folder.ImageSizes == "*" || file.Folder.ImageSizes.ToLower().Split(",").Contains(width.ToString() + "x" + height.ToString())))) + { + imagepath = _imageService.CreateImage(filepath, width, height, mode, position, background, rotateStr, format, imagepath); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Invalid Image Size For Folder {Folder} {Width} {Height}", file.Folder, width, height); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); + } + } + + if (string.IsNullOrWhiteSpace(imagepath)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Create, "Error Displaying Image For File {File} {Width} {Height}", file, widthStr, heightStr); + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return BrokenFile(); + } + + downloadName = file.Name.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + format); + filepath = imagepath; + } + + if (!System.IO.File.Exists(filepath)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Read, "File Does Not Exist {FilePath}", filepath); + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return BrokenFile(); + } + + if (download) + { + _syncManager.AddSyncEvent(_alias, EntityNames.File, file.FileId, "Download"); + return PhysicalFile(filepath, file.GetMimeType(), downloadName); + } + else + { + HttpContext.Response.Headers.Append(HeaderNames.ETag, etag); + return PhysicalFile(filepath, file.GetMimeType()); + } + } + + private PhysicalFileResult BrokenFile() + { // broken link string errorPath = Path.Combine(Utilities.PathCombine(_environment.ContentRootPath, "wwwroot/images"), "error.png"); return PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)); diff --git a/Oqtane.Server/Services/ImageService.cs b/Oqtane.Server/Services/ImageService.cs new file mode 100644 index 00000000..e22f3b8e --- /dev/null +++ b/Oqtane.Server/Services/ImageService.cs @@ -0,0 +1,124 @@ +using Oqtane.Enums; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; +using System.IO; +using System; +using SixLabors.ImageSharp; +using Oqtane.Infrastructure; +using Oqtane.Shared; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Webp; +using System.Linq; + +namespace Oqtane.Services +{ + public class ImageService : IImageService + { + private readonly ILogManager _logger; + private static readonly string[] _formats = ["png", "webp"]; + + public ImageService(ILogManager logger) + { + _logger = logger; + } + + public string[] GetAvailableFormats() + { + return _formats; + } + + public string CreateImage(string filepath, int width, int height, string mode, string position, string background, string rotate, string format, string imagepath) + { + try + { + // params validation + if (!Enum.TryParse(mode, true, out ResizeMode _)) mode = "crop"; + if (!Enum.TryParse(position, true, out AnchorPositionMode _)) position = "center"; + if (!Color.TryParseHex("#" + background, out _)) background = "transparent"; + if (!int.TryParse(rotate, out _)) rotate = "0"; + rotate = (int.Parse(rotate) < 0 || int.Parse(rotate) > 360) ? "0" : rotate; + if (!_formats.Contains(format)) format = "png"; + + using (var stream = new FileStream(filepath, FileMode.Open, FileAccess.Read)) + { + stream.Position = 0; + using (var image = Image.Load(stream)) + { + int.TryParse(rotate, out int angle); + Enum.TryParse(mode, true, out ResizeMode resizemode); + Enum.TryParse(position, true, out AnchorPositionMode anchorpositionmode); + + if (width == 0 && height == 0) + { + width = image.Width; + height = image.Height; + } + + IImageEncoder encoder; + var resizeOptions = new ResizeOptions + { + Mode = resizemode, + Position = anchorpositionmode, + Size = new Size(width, height) + }; + + if (background != "transparent") + { + resizeOptions.PadColor = Color.ParseHex("#" + background); + encoder = GetEncoder(format, transparent: false); + } + else + { + encoder = GetEncoder(format, transparent: true); + } + + image.Mutate(x => x + .AutoOrient() // auto orient the image + .Rotate(angle) + .Resize(resizeOptions)); + + image.Save(imagepath, encoder); + } + } + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, ex, "Error Creating Image For File {FilePath} {Width} {Height} {Mode} {Rotate} {Error}", filepath, width, height, mode, rotate, ex.Message); + imagepath = ""; + } + + return imagepath; + } + + private static IImageEncoder GetEncoder(string format, bool transparent) + { + return format switch + { + "png" => GetPngEncoder(transparent), + "webp" => GetWebpEncoder(transparent), + _ => GetPngEncoder(transparent), + }; + } + + private static PngEncoder GetPngEncoder(bool transparent) + { + return new PngEncoder() + { + ColorType = transparent ? PngColorType.RgbWithAlpha : PngColorType.Rgb, + TransparentColorMode = transparent ? PngTransparentColorMode.Preserve : PngTransparentColorMode.Clear, + BitDepth = PngBitDepth.Bit8, + CompressionLevel = PngCompressionLevel.BestSpeed + }; + } + + private static WebpEncoder GetWebpEncoder(bool transparent) + { + return new WebpEncoder() + { + FileFormat = WebpFileFormatType.Lossy, + Quality = 60, + TransparentColorMode = transparent ? WebpTransparentColorMode.Preserve : WebpTransparentColorMode.Clear, + }; + } + } +} diff --git a/Oqtane.Shared/Interfaces/IImageService.cs b/Oqtane.Shared/Interfaces/IImageService.cs new file mode 100644 index 00000000..f872b72d --- /dev/null +++ b/Oqtane.Shared/Interfaces/IImageService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Oqtane.Services +{ + public interface IImageService + { + public string[] GetAvailableFormats(); + + public string CreateImage(string filepath, int width, int height, string mode, string position, string background, string rotate, string format, string imagepath); + } +}