[backend/drive] Add several media processing configuration options, replacing constants & assumptions

This commit is contained in:
Laura Hausmann 2024-05-02 18:59:49 +02:00
parent 9a662f2bea
commit 744a01d138
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
6 changed files with 178 additions and 30 deletions

View file

@ -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,

View file

@ -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")
};
}
}
}
}

View file

@ -35,4 +35,11 @@ public static class Enums
Restricted = 2,
Public = 3
}
public enum ImageProcessor
{
ImageSharp,
LibVips,
None
}
}

View file

@ -106,6 +106,9 @@ public class DriveService(
public async Task<DriveFile> 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,

View file

@ -18,24 +18,27 @@ namespace Iceshrimp.Backend.Core.Services;
public class ImageProcessor
{
private readonly ILogger<ImageProcessor> _logger;
private readonly ILogger<ImageProcessor> _logger;
private readonly IOptionsMonitor<Config.StorageSection> _config;
#if EnableLibVips
private readonly IOptions<Config.StorageSection> _config;
#endif
public ImageProcessor(ILogger<ImageProcessor> logger, IOptions<Config.StorageSection> config)
public ImageProcessor(ILogger<ImageProcessor> logger, IOptionsMonitor<Config.StorageSection> 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
{

View file

@ -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