diff --git a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs index 6c222dec..c00c32b5 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/MediaController.cs @@ -73,10 +73,10 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro { Id = file.Id, Type = AttachmentEntity.GetType(file.Type), - Url = file.Url, + Url = file.PublicUrl, Blurhash = file.Blurhash, Description = file.Comment, - PreviewUrl = file.ThumbnailUrl, + PreviewUrl = file.PublicThumbnailUrl, RemoteUrl = file.Uri, Sensitive = file.IsSensitive, //Metadata = TODO, diff --git a/Iceshrimp.Backend/Core/Configuration/Config.cs b/Iceshrimp.Backend/Core/Configuration/Config.cs index 9190d502..ef272fae 100644 --- a/Iceshrimp.Backend/Core/Configuration/Config.cs +++ b/Iceshrimp.Backend/Core/Configuration/Config.cs @@ -79,6 +79,8 @@ public sealed class Config public sealed class StorageSection { public readonly TimeSpan? MediaRetentionTimeSpan; + public readonly int? MaxCacheSizeBytes; + public readonly int? MaxUploadSizeBytes; public bool CleanAvatars = false; public bool CleanBanners = false; @@ -125,10 +127,69 @@ public sealed class Config } } - public bool EnableLibVips { get; init; } + public string? MaxUploadSize + { + get => MaxUploadSizeBytes?.ToString(); + init + { + if (value == null || string.IsNullOrWhiteSpace(value) || value.Trim() == "0" || value.Trim() == "-1") + { + MaxUploadSizeBytes = null; + return; + } - public LocalStorageSection? Local { get; init; } - public ObjectStorageSection? ObjectStorage { get; init; } + var hasSuffix = !char.IsAsciiDigit(value.Trim()[^1]); + var substr = hasSuffix ? value.Trim()[..^1] : value.Trim(); + + if (!int.TryParse(substr, out var num)) + throw new Exception("Invalid max upload size"); + + char? suffix = hasSuffix ? value.Trim()[^1] : null; + + MaxUploadSizeBytes = suffix switch + { + null => num, + 'k' or 'K' => num * 1024, + 'm' or 'M' => num * 1024 * 1024, + 'g' or 'G' => num * 1024 * 1024 * 1024, + _ => throw new Exception("Unsupported suffix, use one of: [K]ilobytes, [M]egabytes, [G]igabytes") + }; + } + } + + public string? MaxCacheSize + { + get => MaxCacheSizeBytes?.ToString(); + init + { + if (value == null || string.IsNullOrWhiteSpace(value) || value.Trim() == "0" || value.Trim() == "-1") + { + MaxCacheSizeBytes = null; + return; + } + + var hasSuffix = !char.IsAsciiDigit(value.Trim()[^1]); + var substr = hasSuffix ? value.Trim()[..^1] : value.Trim(); + + if (!int.TryParse(substr, out var num)) + throw new Exception("Invalid max cache size"); + + char? suffix = hasSuffix ? value.Trim()[^1] : null; + + MaxCacheSizeBytes = suffix switch + { + null => num, + 'k' or 'K' => num * 1024, + 'm' or 'M' => num * 1024 * 1024, + 'g' or 'G' => num * 1024 * 1024 * 1024, + _ => throw new Exception("Unsupported suffix, use one of: [K]ilobytes, [M]egabytes, [G]igabytes") + }; + } + } + + public LocalStorageSection? Local { get; init; } + public ObjectStorageSection? ObjectStorage { get; init; } + public MediaProcessingSection MediaProcessing { get; init; } = new(); } public sealed class LocalStorageSection @@ -148,4 +209,42 @@ public sealed class Config public string? SetAcl { get; init; } public bool DisableValidation { get; init; } = false; } + + public sealed class MediaProcessingSection + { + public readonly int MaxFileSizeBytes = 10 * 1024 * 1024; + public Enums.ImageProcessor ImageProcessor { get; init; } = Enums.ImageProcessor.ImageSharp; + public int MaxResolutionMpx { get; init; } = 30; + public bool LocalOnly { get; init; } = false; + + public string MaxFileSize + { + get => MaxFileSizeBytes.ToString(); + init + { + if (value == null || string.IsNullOrWhiteSpace(value) || value.Trim() == "0" || value.Trim() == "-1") + { + throw new + Exception("Invalid max file size, to disable media processing set ImageProcessor to 'None'"); + } + + var hasSuffix = !char.IsAsciiDigit(value.Trim()[^1]); + var substr = hasSuffix ? value.Trim()[..^1] : value.Trim(); + + if (!int.TryParse(substr, out var num)) + throw new Exception("Invalid max file size"); + + char? suffix = hasSuffix ? value.Trim()[^1] : null; + + MaxFileSizeBytes = suffix switch + { + null => num, + 'k' or 'K' => num * 1024, + 'm' or 'M' => num * 1024 * 1024, + 'g' or 'G' => num * 1024 * 1024 * 1024, + _ => throw new Exception("Unsupported suffix, use one of: [K]ilobytes, [M]egabytes, [G]igabytes") + }; + } + } + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Configuration/Enums.cs b/Iceshrimp.Backend/Core/Configuration/Enums.cs index 68719966..71aa51f6 100644 --- a/Iceshrimp.Backend/Core/Configuration/Enums.cs +++ b/Iceshrimp.Backend/Core/Configuration/Enums.cs @@ -35,4 +35,11 @@ public static class Enums Restricted = 2, Public = 3 } + + public enum ImageProcessor + { + ImageSharp, + LibVips, + None + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/DriveService.cs b/Iceshrimp.Backend/Core/Services/DriveService.cs index c5a77481..5fbb3665 100644 --- a/Iceshrimp.Backend/Core/Services/DriveService.cs +++ b/Iceshrimp.Backend/Core/Services/DriveService.cs @@ -106,6 +106,9 @@ public class DriveService( public async Task StoreFile(Stream input, User user, DriveFileCreationRequest request) { + if (user.Host == null && input.Length > storageConfig.Value.MaxUploadSizeBytes) + throw GracefulException.UnprocessableEntity("Attachment is too large."); + await using var data = new BufferedStream(input); var digest = await DigestHelpers.Sha256DigestAsync(data); logger.LogDebug("Storing file {digest} for user {userId}", digest, user.Id); @@ -131,21 +134,26 @@ public class DriveService( data.Seek(0, SeekOrigin.Begin); - var shouldStore = storageConfig.Value.MediaRetention != null || user.Host == null; var storedInternal = storageConfig.Value.Provider == Enums.FileStorage.Local; + var shouldCache = + storageConfig.Value is { MediaRetentionTimeSpan: not null, MediaProcessing.LocalOnly: false } && + data.Length <= storageConfig.Value.MaxCacheSizeBytes; + + var shouldStore = user.Host == null || shouldCache; + if (request.Uri == null && user.Host != null) throw GracefulException.UnprocessableEntity("Refusing to store file without uri for remote user"); string? blurhash = null; - DriveFile.FileProperties? properties = null; + var properties = new DriveFile.FileProperties(); string url; string? thumbnailUrl = null; string? webpublicUrl = null; - var isReasonableSize = data.Length < 10 * 1024 * 1024; // skip images larger than 10MB + var isReasonableSize = data.Length < storageConfig.Value.MediaProcessing.MaxFileSizeBytes; var isImage = request.MimeType.StartsWith("image/") || request.MimeType == "image"; var filename = GenerateFilenameKeepingExtension(request.Filename); @@ -158,6 +166,7 @@ public class DriveService( { var genWebp = user.Host == null; var res = await imageProcessor.ProcessImage(data, request, true, genWebp); + properties = res?.Properties ?? properties; blurhash = res?.Blurhash; thumbnailFilename = res?.RenderThumbnail != null ? GenerateWebpFilename("thumbnail-") : null; @@ -245,7 +254,6 @@ public class DriveService( } else { - //TODO: check against file size limit if (storedInternal) { var pathBase = storageConfig.Value.Local?.Path ?? @@ -290,7 +298,7 @@ public class DriveService( RequestHeaders = request.RequestHeaders, RequestIp = request.RequestIp, Blurhash = blurhash, - Properties = properties!, + Properties = properties, ThumbnailUrl = thumbnailUrl, ThumbnailAccessKey = thumbnailFilename, WebpublicType = webpublicUrl != null ? "image/webp" : null, diff --git a/Iceshrimp.Backend/Core/Services/ImageProcessor.cs b/Iceshrimp.Backend/Core/Services/ImageProcessor.cs index 940c3d3c..59824741 100644 --- a/Iceshrimp.Backend/Core/Services/ImageProcessor.cs +++ b/Iceshrimp.Backend/Core/Services/ImageProcessor.cs @@ -18,24 +18,27 @@ namespace Iceshrimp.Backend.Core.Services; public class ImageProcessor { - private readonly ILogger _logger; + private readonly ILogger _logger; + private readonly IOptionsMonitor _config; - #if EnableLibVips - private readonly IOptions _config; - #endif - - public ImageProcessor(ILogger logger, IOptions config) + public ImageProcessor(ILogger logger, IOptionsMonitor config) { _logger = logger; + _config = config; + + if (config.CurrentValue.MediaProcessing.ImageProcessor == Enums.ImageProcessor.None) + { + _logger.LogInformation("Image processing is disabled as per the configuration."); + return; + } SixLabors.ImageSharp.Configuration.Default.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 20 }); #if EnableLibVips - _config = config; - if (!_config.Value.EnableLibVips) + if (_config.CurrentValue.MediaProcessing.ImageProcessor != Enums.ImageProcessor.LibVips) { - _logger.LogInformation("VIPS support was enabled at compile time, but is not enabled in the configuration, skipping VIPS init"); + _logger.LogDebug("VIPS support was enabled at compile time, but is not enabled in the configuration, skipping VIPS init"); _logger.LogInformation("Using ImageSharp for image processing."); return; } @@ -55,9 +58,9 @@ public class ImageProcessor VipsLogDelegate); _logger.LogInformation("Using VIPS for image processing."); #else - if (config.Value.EnableLibVips) + if (config.CurrentValue.MediaProcessing.ImageProcessor == Enums.ImageProcessor.LibVips) { - _logger.LogWarning("VIPS support was disabled at compile time, but EnableLibVips is set in the configuration. Either compile with -p:EnableLibVips=true, or disable EnableLibVips in the configuration."); + _logger.LogWarning("VIPS support was disabled at compile time, but ImageProcessor is set to LibVips in the configuration. Either compile with -p:EnableLibVips=true, or set the ImageProcessor configuration option to something else."); } else { @@ -91,6 +94,12 @@ public class ImageProcessor if (request.MimeType == "image" && ident.Metadata.DecodedImageFormat?.DefaultMimeType != null) request.MimeType = ident.Metadata.DecodedImageFormat.DefaultMimeType; + if (_config.CurrentValue.MediaProcessing.ImageProcessor == Enums.ImageProcessor.None) + { + var props = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height }; + return new Result { Properties = props }; + } + // Don't generate thumb/webp for animated images if (ident.FrameMetadataCollection.Count != 0) { @@ -98,16 +107,16 @@ public class ImageProcessor genWebp = false; } - if (ident.Width * ident.Height > 30000000) + if (ident.Width * ident.Height > _config.CurrentValue.MediaProcessing.MaxResolutionMpx * 1000 * 1000) { - _logger.LogDebug("Image is larger than 30mpx ({width}x{height}), bypassing image processing pipeline", - ident.Width, ident.Height); + _logger.LogDebug("Image is larger than {mpx}mpx ({width}x{height}), bypassing image processing pipeline", + _config.CurrentValue.MediaProcessing.MaxResolutionMpx, ident.Width, ident.Height); var props = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height }; return new Result { Properties = props }; } #if EnableLibVips - if (_config.Value.EnableLibVips) + if (_config.CurrentValue.MediaProcessing.ImageProcessor == Enums.ImageProcessor.LibVips) { try { diff --git a/Iceshrimp.Backend/configuration.ini b/Iceshrimp.Backend/configuration.ini index 3020aff3..3b06092a 100644 --- a/Iceshrimp.Backend/configuration.ini +++ b/Iceshrimp.Backend/configuration.ini @@ -74,17 +74,19 @@ MaxConnections = 100 ;; Options: [Local, ObjectStorage] Provider = Local +;; Max file size for locally originating media, files larger than this will error on upload (-1 = no limit) +MaxUploadSize = 100M + +;; Max file size for remote media, files larger than this will never be cached (-1 = no limit) +MaxCacheSize = 20M + ;; Amount of time remote media is retained in the cache (0 = disabled, -1 = infinite) MediaRetention = 30d -;; Whether to cleanup avatars & banners past the media retention time +;; Whether to cleanup remote avatars & banners past the media retention time CleanAvatars = false CleanBanners = false -;; Whether to enable LibVIPS support for image processing. Trades faster processing speed for higher memory usage. -;; Note: Requires compilation with -p:EnableLibVips=true -EnableLibVips = false - [Storage:Local] ;; Path where media is stored at. Must be writable for the service user. Path = /path/to/media/location @@ -103,6 +105,29 @@ Path = /path/to/media/location ;; Only enable this if you have a cache in front of the object storage access URL that makes the validation fail on restart. ;;DisableValidation = false +[Storage:MediaProcessing] +;; Which image processor to use. +;; +;; ImageSharp = .NET library, slower, lower memory footprint. No external dependencies. +;; LibVips = Native library, faster, higher and spikier memory footprint. Requires compilation with -p:EnableLibVips=true & for libvips to be installed on the system. +;; None = Disables image processing, fastest, lowest memory footprint. Caution: metadata (e.g. location data) for locally originating images will *not* be stripped! +;; +;; Options: [ImageSharp, LibVips, None] +ImageProcessor = ImageSharp + +;; Whether to only process locally originating media. This is useful if you're working with a cpu-constrained environment, +;; and want both remote media caching and local media processing. +LocalOnly = false + +;; Maximum file size for files to be considered for image processing. +;; Caution: metadata (e.g. location data) for locally originating images will *not* be stripped for files larger than this +MaxFileSize = 10M + +;; Maximum resolution for files to be considered for image processing, in megapixels +;; Note that processing an image requires up to 4MB of system memory per megapixel, in some edge case scenarios. +;; Caution: metadata (e.g. location data) for locally originating images will *not* be stripped for files larger than this +MaxResolutionMpx = 30 + [Logging:LogLevel] Default = Information Iceshrimp = Information