using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Services.ImageProcessing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using static Iceshrimp.Backend.Core.Services.ImageProcessing.ImageVersion; namespace Iceshrimp.Backend.Core.Services; using ImageVerTriple = (ImageVersion format, string accessKey, string url); public class DriveService( DatabaseContext db, ObjectStorageService storageSvc, [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")] IOptionsSnapshot storageConfig, IOptions instanceConfig, HttpClient httpClient, QueueService queueSvc, ILogger logger, ImageProcessor imageProcessor ) : IScopedService { public async Task StoreFileAsync( string? uri, User user, bool sensitive, string? description = null, string? mimeType = null, bool logExisting = true, bool forceStore = false, bool skipImageProcessing = false ) { if (uri == null) return null; if (logExisting) logger.LogDebug("Storing file {uri} for user {userId}", uri, user.Id); if (string.IsNullOrWhiteSpace(description)) description = null; try { // Do we already have the file? DriveFile? file = null; if (!forceStore) file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Uri == uri && (!p.IsLink || p.UserId == user.Id)); if (file != null) { // If the user matches, return the existing file if (file.UserId == user.Id) { if (logExisting) { logger.LogDebug("File {uri} is already registered for user, returning existing file {id}", uri, file.Id); } if (file.Comment != description) { file.Comment = description; db.Update(file); await db.SaveChangesAsync(); } return file; } if (!logExisting) logger.LogDebug("Storing file {uri} for user {userId}", uri, user.Id); // Otherwise, clone the file var req = new DriveFileCreationRequest { Uri = uri, IsSensitive = sensitive, Comment = description, Filename = file.Name, MimeType = null! // Not needed in .Clone }; var clonedFile = file.Clone(user, req); logger.LogDebug("File {uri} is already registered for different user, returning clone of existing file {id}, stored as {cloneId}", uri, file.Id, clonedFile.Id); await db.AddAsync(clonedFile); await db.SaveChangesAsync(); return clonedFile; } if (!logExisting) logger.LogDebug("Storing file {uri} for user {userId}", uri, user.Id); try { var res = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); res.EnsureSuccessStatusCode(); var filename = res.Content.Headers.ContentDisposition?.FileName ?? new Uri(uri).AbsolutePath.Split('/').LastOrDefault() ?? ""; var request = new DriveFileCreationRequest { Uri = uri, Filename = filename, IsSensitive = sensitive, Comment = description, MimeType = CleanMimeType(res.Content.Headers.ContentType?.MediaType ?? mimeType) }; var input = await res.Content.ReadAsStreamAsync(); var maxLength = user.IsLocalUser ? storageConfig.Value.MaxUploadSizeBytes : storageConfig.Value.MediaRetentionTimeSpan != null ? storageConfig.Value.MaxCacheSizeBytes : 0; var stream = await input.GetSafeStreamOrNullAsync(maxLength, res.Content.Headers.ContentLength); try { return await StoreFileAsync(stream, user, request, skipImageProcessing); } catch (Exception e) { logger.LogWarning("Failed to store downloaded file from {uri}: {error}, storing as link", uri, e); throw; } } catch (Exception e) { logger.LogDebug("Failed to download file from {uri}: {error}, storing as link", uri, e.Message); file = new DriveFile { Id = IdHelpers.GenerateSnowflakeId(), CreatedAt = DateTime.UtcNow, User = user, UserHost = user.Host, Size = 0, IsLink = true, IsSensitive = sensitive, StoredInternal = false, Uri = uri, Url = uri, Name = new Uri(uri).AbsolutePath.Split('/').LastOrDefault() ?? "", Comment = description, Type = CleanMimeType(mimeType ?? "application/octet-stream"), AccessKey = Guid.NewGuid().ToStringLower() }; db.Add(file); await db.SaveChangesAsync(); return file; } } catch (Exception e) { logger.LogError("Failed to insert file {uri}: {error}", uri, e.Message); return null; } } public async Task StoreFileAsync( Stream input, User user, DriveFileCreationRequest request, bool skipImageProcessing = false ) { if (user.IsLocalUser && input.Length > storageConfig.Value.MaxUploadSizeBytes) throw GracefulException.UnprocessableEntity("Attachment is too large."); DriveFile? file; request.Filename = request.Filename.Trim('"'); if (input == Stream.Null || (user.IsRemoteUser && input.Length > storageConfig.Value.MaxCacheSizeBytes)) { file = new DriveFile { Id = IdHelpers.GenerateSnowflakeId(), CreatedAt = DateTime.UtcNow, User = user, UserHost = user.Host, Size = (int)input.Length, IsLink = true, IsSensitive = request.IsSensitive, StoredInternal = false, Src = request.Source, Uri = request.Uri, Url = request.Uri ?? throw new Exception("Cannot store remote attachment without URI"), Name = request.Filename, Comment = request.Comment, Type = CleanMimeType(request.MimeType), RequestHeaders = request.RequestHeaders, RequestIp = request.RequestIp, AccessKey = Guid.NewGuid().ToStringLower() + Path.GetExtension(request.Filename) }; db.Add(file); await db.SaveChangesAsync(); return file; } var buf = new byte[input.Length]; using (var memoryStream = new MemoryStream(buf)) await input.CopyToAsync(memoryStream); // If the requested folder doesn't exist then store it in the root folder if (request.FolderId != null) { var folder = await db.DriveFolders .FirstOrDefaultAsync(p => p.Id == request.FolderId && p.UserId == user.Id); if (folder == null) request.FolderId = null; } var digest = await DigestHelpers.Sha256DigestAsync(buf); logger.LogDebug("Storing file {digest} for user {userId}", digest, user.Id); file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Sha256 == digest && (!p.IsLink || p.UserId == user.Id)); if (file != null) { if (file.UserId == user.Id && file.FolderId == request.FolderId) { logger.LogDebug("File {digest} is already registered for user, returning existing file {id}", digest, file.Id); return file; } var clonedFile = file.Clone(user, request); logger.LogDebug("File {digest} is already registered for different user or folder, returning clone of existing file {id}, stored as {cloneId}", digest, file.Id, clonedFile.Id); await db.AddAsync(clonedFile); await db.SaveChangesAsync(); return clonedFile; } var storedInternal = storageConfig.Value.Provider == Enums.FileStorage.Local; var shouldCache = storageConfig.Value is { MediaRetentionTimeSpan: not null, MediaProcessing.LocalOnly: false } && buf.Length <= storageConfig.Value.MaxCacheSizeBytes; var shouldStore = user.IsLocalUser || shouldCache; if (request.Uri == null && user.IsRemoteUser) throw GracefulException.UnprocessableEntity("Refusing to store file without uri for remote user"); string? blurhash = null; var properties = new DriveFile.FileProperties(); ImageVerTriple? original = null; ImageVerTriple? thumbnail = null; ImageVerTriple? @public = null; var isReasonableSize = buf.Length < storageConfig.Value.MediaProcessing.MaxFileSizeBytes; var isImage = request.MimeType.StartsWith("image/") || request.MimeType == "image"; if (shouldStore) { if (isImage && isReasonableSize) { var ident = imageProcessor.IdentifyImage(buf, request); if (ident == null) { logger.LogWarning("imageProcessor.IdentifyImage() returned null, skipping image processing"); original = await StoreOriginalFileOnly(input, request); } else { if (ident.IsAnimated) { logger.LogDebug("Image is animated, bypassing image processing..."); skipImageProcessing = true; } else if (ident.Width * ident.Height > storageConfig.Value.MediaProcessing.MaxResolutionPx) { var config = storageConfig.Value.MediaProcessing; if (config.FailIfImageExceedsMaxRes) { // @formatter:off throw GracefulException.UnprocessableEntity($"Image is larger than {config.MaxResolutionMpx}mpx. Please resize your image to fit within the allowed dimensions."); // @formatter:on } logger.LogDebug("Image is larger than {mpx}mpx ({width}x{height}), bypassing image processing...", config.MaxResolutionMpx, ident.Width, ident.Height); skipImageProcessing = true; } var formats = GetFormats(user, request, skipImageProcessing); var res = imageProcessor.ProcessImage(buf, ident, request, formats); properties = res; blurhash = res.Blurhash; var processed = await res.RequestedFormats .Select(p => ProcessAndStoreFileVersionAsync(p.Key, p.Value, request.Filename)) .AwaitAllNoConcurrencyAsync() .ContinueWithResult(p => p.ToImmutableArray()); original = processed.FirstOrDefault(p => p?.format.Key == KeyEnum.Original) ?? throw new Exception("Image processing didn't result in an original version"); thumbnail = processed.FirstOrDefault(p => p?.format.Key == KeyEnum.Thumbnail); @public = processed.FirstOrDefault(p => p?.format.Key == KeyEnum.Public); if (@public == null && user.IsLocalUser && !skipImageProcessing) { var publicLocalFormat = storageConfig.Value.MediaProcessing.ImagePipeline.Public.Local.Format; if (publicLocalFormat is not ImageFormatEnum.Keep and not ImageFormatEnum.None) throw new Exception("Failed to re-encode image, bailing due to risk of metadata leakage"); } } } else { original = await StoreOriginalFileOnly(input, request); } } else { if (request.Uri == null) throw new Exception("Uri must not be null at this stage"); } if (original?.format.Format is { } fmt and not ImageFormat.Keep) { request.MimeType = fmt.MimeType; request.Filename += $".{fmt.Extension}"; } file = new DriveFile { Id = IdHelpers.GenerateSnowflakeId(), CreatedAt = DateTime.UtcNow, User = user, UserHost = user.Host, Sha256 = digest, Size = buf.Length, IsLink = !shouldStore, AccessKey = original?.accessKey ?? Guid.NewGuid().ToStringLower(), IsSensitive = request.IsSensitive, StoredInternal = storedInternal, Src = request.Source, Uri = request.Uri, Url = original?.url ?? request.Uri ?? throw new Exception("Uri must not be null here"), Name = request.Filename, Comment = request.Comment, Type = CleanMimeType(request.MimeType), RequestHeaders = request.RequestHeaders, RequestIp = request.RequestIp, Blurhash = blurhash, Properties = properties, ThumbnailUrl = thumbnail?.url, ThumbnailAccessKey = thumbnail?.accessKey, ThumbnailMimeType = thumbnail?.format.Format.MimeType, PublicUrl = @public?.url, PublicAccessKey = @public?.accessKey, PublicMimeType = @public?.format.Format.MimeType, FolderId = request.FolderId }; await db.AddAsync(file); await db.SaveChangesAsync(); return file; } private async Task StoreOriginalFileOnly( Stream input, DriveFileCreationRequest request ) { var accessKey = GenerateAccessKey(extension: Path.GetExtension(request.Filename).TrimStart('.')).TrimStart('-'); var url = await StoreFileVersionAsync(input, accessKey, request.Filename, request.MimeType); return (Stub, accessKey, url); } private async Task ProcessAndStoreFileVersionAsync( ImageVersion version, Func>? encode, string fileName ) { if (encode == null) return null; var accessKey = GenerateAccessKey(version.Key.ToString().ToLowerInvariant(), version.Format.Extension); Stream? stream = null; try { try { var sw = Stopwatch.StartNew(); stream = await encode(); sw.Stop(); logger.LogDebug("Encoding {version} image took {ms} ms", version.Key.ToString().ToLowerInvariant(), sw.ElapsedMilliseconds); } catch (Exception e) { logger.LogWarning("Failed to process {ext} file version: {e}", version.Format.Extension, e.Message); return null; } fileName = GenerateDerivedFileName(fileName, version.Format.Extension); var url = await StoreFileVersionAsync(stream, accessKey, fileName, version.Format.MimeType); return (version, accessKey, url); } finally { if (stream != null) await stream.DisposeAsync(); } } private Task StoreFileVersionAsync(Stream stream, string accessKey, string fileName, string mimeType) { // @formatter:off return storageConfig.Value.Provider switch { Enums.FileStorage.Local => StoreFileVersionLocalStorageAsync(stream, accessKey), Enums.FileStorage.ObjectStorage => StoreFileVersionObjectStorageAsync(stream, accessKey, fileName, mimeType), _ => throw new ArgumentOutOfRangeException() }; // @formatter:on } private async Task StoreFileVersionLocalStorageAsync(Stream stream, string filename) { var pathBase = storageConfig.Value.Local?.Path ?? throw new Exception("Local storage path cannot be null"); var path = Path.Combine(pathBase, filename); await using var writer = File.OpenWrite(path); stream.Seek(0, SeekOrigin.Begin); await stream.CopyToAsync(writer); return $"https://{instanceConfig.Value.WebDomain}/files/{filename}"; } private async Task StoreFileVersionObjectStorageAsync( Stream stream, string accessKey, string filename, string mimeType ) { stream.Seek(0, SeekOrigin.Begin); await storageSvc.UploadFileAsync(accessKey, mimeType, filename, stream); return storageSvc.GetFilePublicUrl(accessKey).AbsoluteUri; } public async Task RemoveFileAsync(DriveFile file) { await RemoveFileAsync(file.Id); } public async Task RemoveFileAsync(string fileId) { var job = new DriveFileDeleteJobData { DriveFileId = fileId, Expire = false }; await queueSvc.BackgroundTaskQueue.EnqueueAsync(job); } public async Task ExpireFileAsync( DriveFile file, bool ignoreDeletionFailures = false, CancellationToken token = default ) { if (file is not { UserHost: not null, Uri: not null, IsLink: false }) return; string?[] paths = [file.AccessKey, file.ThumbnailAccessKey, file.PublicAccessKey]; var storedInternal = file.StoredInternal; file.IsLink = true; file.Url = file.Uri; file.ThumbnailUrl = null; file.PublicUrl = null; file.ThumbnailAccessKey = null; file.PublicAccessKey = null; file.ThumbnailMimeType = null; file.PublicMimeType = null; file.StoredInternal = false; await db.SaveChangesAsync(token); var deduplicated = await db.DriveFiles.AnyAsync(p => p.Id != file.Id && p.AccessKey == file.AccessKey && !p.IsLink, token); if (deduplicated) return; try { if (storedInternal) { var pathBase = storageConfig.Value.Local?.Path ?? throw new Exception("Cannot delete locally stored file: pathBase is null"); paths.Where(p => p != null) .Select(p => Path.Combine(pathBase, p!)) .Where(File.Exists) .ToList() .ForEach(File.Delete); } else { await storageSvc.RemoveFilesAsync(paths.Where(p => p != null).Select(p => p!).ToArray()); } } catch when (ignoreDeletionFailures) { // ignored } } public async Task CreateFolderAsync(User user, string name, string? parentId) { if (parentId != null) { var parent = await db.DriveFolders .FirstOrDefaultAsync(p => p.Id == parentId && p.UserId == user.Id); if (parent == null) throw GracefulException.NotFound("The parent folder does not exist"); } var existing = await db.DriveFolders .FirstOrDefaultAsync(p => p.Name == name && p.ParentId == parentId && p.UserId == user.Id); if (existing != null) throw GracefulException.Conflict("A folder with this name already exists"); var folder = new DriveFolder { Id = IdHelpers.GenerateSnowflakeId(), CreatedAt = default, Name = name, UserId = user.Id, ParentId = parentId }; db.Add(folder); await db.SaveChangesAsync(); return folder; } public async Task> GetAllFileNamesFromObjectStorageAsync() { return storageConfig.Value.ObjectStorage?.Bucket != null ? await storageSvc.EnumerateFiles().ToArrayAsync().AsTask().ContinueWithResult(p => p.ToHashSet()) : []; } public HashSet GetAllFileNamesFromLocalStorage() { return storageConfig.Value.Local?.Path is { } path && Directory.Exists(path) ? Directory.EnumerateFiles(path).Select(Path.GetFileName).NotNull().ToHashSet() : []; } public static bool VerifyFileExistence( DriveFile file, HashSet objectStorageFiles, HashSet localStorageFiles, out bool original, out bool thumbnail, out bool @public ) { string?[] allFilenames = [file.AccessKey, file.ThumbnailAccessKey, file.PublicAccessKey]; var filenames = allFilenames.NotNull().ToArray(); var missing = file.StoredInternal ? filenames.Where(p => !localStorageFiles.Contains(p)).ToArray() : filenames.Where(p => !objectStorageFiles.Contains(p)).ToArray(); original = !missing.Contains(file.AccessKey); thumbnail = file.ThumbnailAccessKey == null || !missing.Contains(file.ThumbnailAccessKey); @public = file.PublicAccessKey == null || !missing.Contains(file.PublicAccessKey); return original && thumbnail && @public; } private static string GenerateDerivedFileName(string filename, string newExt) { return filename.EndsWith($".{newExt}") ? filename : $"{filename}.{newExt}"; } private static string GenerateAccessKey(string prefix = "", string extension = "webp") { var guid = Guid.NewGuid().ToStringLower(); return extension.Length > 0 ? $"{prefix}-{guid}.{extension}" : $"{prefix}-{guid}"; } private static string CleanMimeType(string? mimeType) { return mimeType == null || !Constants.BrowserSafeMimeTypes.Contains(mimeType) ? "application/octet-stream" : mimeType; } private IReadOnlyCollection GetFormats( User user, DriveFileCreationRequest request, bool skipImageProcessing ) { if (skipImageProcessing) { var origFormat = new ImageFormat.Keep(Path.GetExtension(request.Filename).TrimStart('.'), request.MimeType); return [new ImageVersion(KeyEnum.Original, origFormat)]; } return Enum.GetValues() .ToDictionary(p => p, p => GetFormatFromConfig(request, user, p)) .Where(p => p.Value != null) .Select(p => new ImageVersion(p.Key, p.Value!)) .ToImmutableArray() .AsReadOnly(); } private ImageFormat? GetFormatFromConfig(DriveFileCreationRequest request, User user, KeyEnum key) { var ver = key switch { KeyEnum.Original => storageConfig.Value.MediaProcessing.ImagePipeline.Original, KeyEnum.Thumbnail => storageConfig.Value.MediaProcessing.ImagePipeline.Thumbnail, KeyEnum.Public => storageConfig.Value.MediaProcessing.ImagePipeline.Public, _ => throw new ArgumentOutOfRangeException() }; var config = user.IsLocalUser ? ver.Local : ver.Remote; // @formatter:off return config.Format switch { ImageFormatEnum.None => null, ImageFormatEnum.Keep => new ImageFormat.Keep(Path.GetExtension(request.Filename).TrimStart('.'), request.MimeType), ImageFormatEnum.Webp => new ImageFormat.Webp(config.WebpCompressionMode, GetQualityFactor(), GetTargetRes()), ImageFormatEnum.Avif => new ImageFormat.Avif(config.AvifCompressionMode, GetQualityFactor(), config.AvifBitDepth, GetTargetRes()), ImageFormatEnum.Jxl => new ImageFormat.Jxl(config.JxlCompressionMode, GetQualityFactor(), config.JxlEffort, GetTargetRes()), _ => throw new ArgumentOutOfRangeException() }; int GetQualityFactor() => request.MimeType == "image/png" ? config.QualityFactorPngSource : config.QualityFactor; int GetTargetRes() => config.TargetRes ?? throw new Exception("TargetRes is required to encode images"); // @formatter:on } } public class DriveFileCreationRequest { public string? Comment; public required string Filename = Guid.NewGuid().ToStringLower(); public required bool IsSensitive; public required string MimeType; public Dictionary? RequestHeaders; public string? RequestIp; public string? Source; public string? Uri; public string? FolderId; } file static class DriveFileExtensions { public static DriveFile Clone(this DriveFile file, User user, DriveFileCreationRequest request) { if (file.IsLink) throw new Exception("Refusing to clone remote file"); return new DriveFile { Id = IdHelpers.GenerateSnowflakeId(), CreatedAt = DateTime.UtcNow, User = user, Blurhash = file.Blurhash, Type = file.Type, Sha256 = file.Sha256, Name = request.Filename, Properties = file.Properties, Size = file.Size, Src = request.Source, IsLink = false, Uri = request.Uri, Url = file.Url, AccessKey = file.AccessKey, ThumbnailUrl = file.ThumbnailUrl, IsSensitive = request.IsSensitive, PublicMimeType = file.PublicMimeType, PublicUrl = file.PublicUrl, PublicAccessKey = file.PublicAccessKey, StoredInternal = file.StoredInternal, UserHost = user.Host, Comment = request.Comment, RequestHeaders = request.RequestHeaders, RequestIp = request.RequestIp, ThumbnailAccessKey = file.ThumbnailAccessKey, FolderId = request.FolderId }; } }