[backend/drive] Add several media processing configuration options, replacing constants & assumptions
This commit is contained in:
parent
9a662f2bea
commit
744a01d138
6 changed files with 178 additions and 30 deletions
|
@ -73,10 +73,10 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
|
||||||
{
|
{
|
||||||
Id = file.Id,
|
Id = file.Id,
|
||||||
Type = AttachmentEntity.GetType(file.Type),
|
Type = AttachmentEntity.GetType(file.Type),
|
||||||
Url = file.Url,
|
Url = file.PublicUrl,
|
||||||
Blurhash = file.Blurhash,
|
Blurhash = file.Blurhash,
|
||||||
Description = file.Comment,
|
Description = file.Comment,
|
||||||
PreviewUrl = file.ThumbnailUrl,
|
PreviewUrl = file.PublicThumbnailUrl,
|
||||||
RemoteUrl = file.Uri,
|
RemoteUrl = file.Uri,
|
||||||
Sensitive = file.IsSensitive,
|
Sensitive = file.IsSensitive,
|
||||||
//Metadata = TODO,
|
//Metadata = TODO,
|
||||||
|
|
|
@ -79,6 +79,8 @@ public sealed class Config
|
||||||
public sealed class StorageSection
|
public sealed class StorageSection
|
||||||
{
|
{
|
||||||
public readonly TimeSpan? MediaRetentionTimeSpan;
|
public readonly TimeSpan? MediaRetentionTimeSpan;
|
||||||
|
public readonly int? MaxCacheSizeBytes;
|
||||||
|
public readonly int? MaxUploadSizeBytes;
|
||||||
|
|
||||||
public bool CleanAvatars = false;
|
public bool CleanAvatars = false;
|
||||||
public bool CleanBanners = 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; }
|
var hasSuffix = !char.IsAsciiDigit(value.Trim()[^1]);
|
||||||
public ObjectStorageSection? ObjectStorage { get; init; }
|
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
|
public sealed class LocalStorageSection
|
||||||
|
@ -148,4 +209,42 @@ public sealed class Config
|
||||||
public string? SetAcl { get; init; }
|
public string? SetAcl { get; init; }
|
||||||
public bool DisableValidation { get; init; } = false;
|
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")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -35,4 +35,11 @@ public static class Enums
|
||||||
Restricted = 2,
|
Restricted = 2,
|
||||||
Public = 3
|
Public = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ImageProcessor
|
||||||
|
{
|
||||||
|
ImageSharp,
|
||||||
|
LibVips,
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -106,6 +106,9 @@ public class DriveService(
|
||||||
|
|
||||||
public async Task<DriveFile> StoreFile(Stream input, User user, DriveFileCreationRequest request)
|
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);
|
await using var data = new BufferedStream(input);
|
||||||
var digest = await DigestHelpers.Sha256DigestAsync(data);
|
var digest = await DigestHelpers.Sha256DigestAsync(data);
|
||||||
logger.LogDebug("Storing file {digest} for user {userId}", digest, user.Id);
|
logger.LogDebug("Storing file {digest} for user {userId}", digest, user.Id);
|
||||||
|
@ -131,21 +134,26 @@ public class DriveService(
|
||||||
|
|
||||||
data.Seek(0, SeekOrigin.Begin);
|
data.Seek(0, SeekOrigin.Begin);
|
||||||
|
|
||||||
var shouldStore = storageConfig.Value.MediaRetention != null || user.Host == null;
|
|
||||||
var storedInternal = storageConfig.Value.Provider == Enums.FileStorage.Local;
|
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)
|
if (request.Uri == null && user.Host != null)
|
||||||
throw GracefulException.UnprocessableEntity("Refusing to store file without uri for remote user");
|
throw GracefulException.UnprocessableEntity("Refusing to store file without uri for remote user");
|
||||||
|
|
||||||
string? blurhash = null;
|
string? blurhash = null;
|
||||||
|
|
||||||
DriveFile.FileProperties? properties = null;
|
var properties = new DriveFile.FileProperties();
|
||||||
|
|
||||||
string url;
|
string url;
|
||||||
string? thumbnailUrl = null;
|
string? thumbnailUrl = null;
|
||||||
string? webpublicUrl = 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 isImage = request.MimeType.StartsWith("image/") || request.MimeType == "image";
|
||||||
var filename = GenerateFilenameKeepingExtension(request.Filename);
|
var filename = GenerateFilenameKeepingExtension(request.Filename);
|
||||||
|
|
||||||
|
@ -158,6 +166,7 @@ public class DriveService(
|
||||||
{
|
{
|
||||||
var genWebp = user.Host == null;
|
var genWebp = user.Host == null;
|
||||||
var res = await imageProcessor.ProcessImage(data, request, true, genWebp);
|
var res = await imageProcessor.ProcessImage(data, request, true, genWebp);
|
||||||
|
properties = res?.Properties ?? properties;
|
||||||
|
|
||||||
blurhash = res?.Blurhash;
|
blurhash = res?.Blurhash;
|
||||||
thumbnailFilename = res?.RenderThumbnail != null ? GenerateWebpFilename("thumbnail-") : null;
|
thumbnailFilename = res?.RenderThumbnail != null ? GenerateWebpFilename("thumbnail-") : null;
|
||||||
|
@ -245,7 +254,6 @@ public class DriveService(
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
//TODO: check against file size limit
|
|
||||||
if (storedInternal)
|
if (storedInternal)
|
||||||
{
|
{
|
||||||
var pathBase = storageConfig.Value.Local?.Path ??
|
var pathBase = storageConfig.Value.Local?.Path ??
|
||||||
|
@ -290,7 +298,7 @@ public class DriveService(
|
||||||
RequestHeaders = request.RequestHeaders,
|
RequestHeaders = request.RequestHeaders,
|
||||||
RequestIp = request.RequestIp,
|
RequestIp = request.RequestIp,
|
||||||
Blurhash = blurhash,
|
Blurhash = blurhash,
|
||||||
Properties = properties!,
|
Properties = properties,
|
||||||
ThumbnailUrl = thumbnailUrl,
|
ThumbnailUrl = thumbnailUrl,
|
||||||
ThumbnailAccessKey = thumbnailFilename,
|
ThumbnailAccessKey = thumbnailFilename,
|
||||||
WebpublicType = webpublicUrl != null ? "image/webp" : null,
|
WebpublicType = webpublicUrl != null ? "image/webp" : null,
|
||||||
|
|
|
@ -18,24 +18,27 @@ namespace Iceshrimp.Backend.Core.Services;
|
||||||
|
|
||||||
public class ImageProcessor
|
public class ImageProcessor
|
||||||
{
|
{
|
||||||
private readonly ILogger<ImageProcessor> _logger;
|
private readonly ILogger<ImageProcessor> _logger;
|
||||||
|
private readonly IOptionsMonitor<Config.StorageSection> _config;
|
||||||
|
|
||||||
#if EnableLibVips
|
public ImageProcessor(ILogger<ImageProcessor> logger, IOptionsMonitor<Config.StorageSection> config)
|
||||||
private readonly IOptions<Config.StorageSection> _config;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public ImageProcessor(ILogger<ImageProcessor> logger, IOptions<Config.StorageSection> config)
|
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_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 =
|
SixLabors.ImageSharp.Configuration.Default.MemoryAllocator =
|
||||||
MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 20 });
|
MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 20 });
|
||||||
|
|
||||||
#if EnableLibVips
|
#if EnableLibVips
|
||||||
_config = config;
|
if (_config.CurrentValue.MediaProcessing.ImageProcessor != Enums.ImageProcessor.LibVips)
|
||||||
if (!_config.Value.EnableLibVips)
|
|
||||||
{
|
{
|
||||||
_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.");
|
_logger.LogInformation("Using ImageSharp for image processing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -55,9 +58,9 @@ public class ImageProcessor
|
||||||
VipsLogDelegate);
|
VipsLogDelegate);
|
||||||
_logger.LogInformation("Using VIPS for image processing.");
|
_logger.LogInformation("Using VIPS for image processing.");
|
||||||
#else
|
#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
|
else
|
||||||
{
|
{
|
||||||
|
@ -91,6 +94,12 @@ public class ImageProcessor
|
||||||
if (request.MimeType == "image" && ident.Metadata.DecodedImageFormat?.DefaultMimeType != null)
|
if (request.MimeType == "image" && ident.Metadata.DecodedImageFormat?.DefaultMimeType != null)
|
||||||
request.MimeType = ident.Metadata.DecodedImageFormat.DefaultMimeType;
|
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
|
// Don't generate thumb/webp for animated images
|
||||||
if (ident.FrameMetadataCollection.Count != 0)
|
if (ident.FrameMetadataCollection.Count != 0)
|
||||||
{
|
{
|
||||||
|
@ -98,16 +107,16 @@ public class ImageProcessor
|
||||||
genWebp = false;
|
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",
|
_logger.LogDebug("Image is larger than {mpx}mpx ({width}x{height}), bypassing image processing pipeline",
|
||||||
ident.Width, ident.Height);
|
_config.CurrentValue.MediaProcessing.MaxResolutionMpx, ident.Width, ident.Height);
|
||||||
var props = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
|
var props = new DriveFile.FileProperties { Width = ident.Size.Width, Height = ident.Size.Height };
|
||||||
return new Result { Properties = props };
|
return new Result { Properties = props };
|
||||||
}
|
}
|
||||||
|
|
||||||
#if EnableLibVips
|
#if EnableLibVips
|
||||||
if (_config.Value.EnableLibVips)
|
if (_config.CurrentValue.MediaProcessing.ImageProcessor == Enums.ImageProcessor.LibVips)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
@ -74,17 +74,19 @@ MaxConnections = 100
|
||||||
;; Options: [Local, ObjectStorage]
|
;; Options: [Local, ObjectStorage]
|
||||||
Provider = Local
|
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)
|
;; Amount of time remote media is retained in the cache (0 = disabled, -1 = infinite)
|
||||||
MediaRetention = 30d
|
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
|
CleanAvatars = false
|
||||||
CleanBanners = 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]
|
[Storage:Local]
|
||||||
;; Path where media is stored at. Must be writable for the service user.
|
;; Path where media is stored at. Must be writable for the service user.
|
||||||
Path = /path/to/media/location
|
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.
|
;; 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
|
;;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]
|
[Logging:LogLevel]
|
||||||
Default = Information
|
Default = Information
|
||||||
Iceshrimp = Information
|
Iceshrimp = Information
|
||||||
|
|
Loading…
Add table
Reference in a new issue