727 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			727 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.Globalization;
 | 
						|
using FFMpegCore;
 | 
						|
using System.Security.Cryptography;
 | 
						|
using DysonNetwork.Drive.Storage.Model;
 | 
						|
using DysonNetwork.Shared.Cache;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using Google.Protobuf.WellKnownTypes;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using Minio;
 | 
						|
using Minio.DataModel.Args;
 | 
						|
using NATS.Client.Core;
 | 
						|
using NetVips;
 | 
						|
using NodaTime;
 | 
						|
using System.Linq.Expressions;
 | 
						|
using Microsoft.EntityFrameworkCore.Query;
 | 
						|
using NATS.Net;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
 | 
						|
namespace DysonNetwork.Drive.Storage;
 | 
						|
 | 
						|
public class FileService(
 | 
						|
    AppDatabase db,
 | 
						|
    ILogger<FileService> logger,
 | 
						|
    ICacheService cache,
 | 
						|
    INatsConnection nats
 | 
						|
)
 | 
						|
{
 | 
						|
    private const string CacheKeyPrefix = "file:";
 | 
						|
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
 | 
						|
 | 
						|
    public async Task<SnCloudFile?> GetFileAsync(string fileId)
 | 
						|
    {
 | 
						|
        var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
						|
 | 
						|
        var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
						|
        if (cachedFile is not null)
 | 
						|
            return cachedFile;
 | 
						|
 | 
						|
        var file = await db.Files
 | 
						|
            .Where(f => f.Id == fileId)
 | 
						|
            .Include(f => f.Pool)
 | 
						|
            .Include(f => f.Bundle)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        if (file != null)
 | 
						|
            await cache.SetAsync(cacheKey, file, CacheDuration);
 | 
						|
 | 
						|
        return file;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<List<SnCloudFile>> GetFilesAsync(List<string> fileIds)
 | 
						|
    {
 | 
						|
        var cachedFiles = new Dictionary<string, SnCloudFile>();
 | 
						|
        var uncachedIds = new List<string>();
 | 
						|
 | 
						|
        foreach (var fileId in fileIds)
 | 
						|
        {
 | 
						|
            var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
						|
            var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
						|
 | 
						|
            if (cachedFile != null)
 | 
						|
                cachedFiles[fileId] = cachedFile;
 | 
						|
            else
 | 
						|
                uncachedIds.Add(fileId);
 | 
						|
        }
 | 
						|
 | 
						|
        if (uncachedIds.Count > 0)
 | 
						|
        {
 | 
						|
            var dbFiles = await db.Files
 | 
						|
                .Where(f => uncachedIds.Contains(f.Id))
 | 
						|
                .Include(f => f.Pool)
 | 
						|
                .ToListAsync();
 | 
						|
 | 
						|
            foreach (var file in dbFiles)
 | 
						|
            {
 | 
						|
                var cacheKey = $"{CacheKeyPrefix}{file.Id}";
 | 
						|
                await cache.SetAsync(cacheKey, file, CacheDuration);
 | 
						|
                cachedFiles[file.Id] = file;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return fileIds
 | 
						|
            .Select(f => cachedFiles.GetValueOrDefault(f))
 | 
						|
            .Where(f => f != null)
 | 
						|
            .Cast<SnCloudFile>()
 | 
						|
            .ToList();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SnCloudFile> ProcessNewFileAsync(
 | 
						|
        Account account,
 | 
						|
        string fileId,
 | 
						|
        string filePool,
 | 
						|
        string? fileBundleId,
 | 
						|
        string filePath,
 | 
						|
        string fileName,
 | 
						|
        string? contentType,
 | 
						|
        string? encryptPassword,
 | 
						|
        Instant? expiredAt
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var accountId = Guid.Parse(account.Id);
 | 
						|
 | 
						|
        var pool = await GetPoolAsync(Guid.Parse(filePool));
 | 
						|
        if (pool is null) throw new InvalidOperationException("Pool not found");
 | 
						|
 | 
						|
        if (pool.StorageConfig.Expiration is not null && expiredAt.HasValue)
 | 
						|
        {
 | 
						|
            var expectedExpiration = SystemClock.Instance.GetCurrentInstant() - expiredAt.Value;
 | 
						|
            var effectiveExpiration = pool.StorageConfig.Expiration < expectedExpiration
 | 
						|
                ? pool.StorageConfig.Expiration
 | 
						|
                : expectedExpiration;
 | 
						|
            expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
 | 
						|
        }
 | 
						|
 | 
						|
        var bundle = fileBundleId is not null
 | 
						|
            ? await GetBundleAsync(Guid.Parse(fileBundleId), accountId)
 | 
						|
            : null;
 | 
						|
        if (fileBundleId is not null && bundle is null)
 | 
						|
        {
 | 
						|
            throw new InvalidOperationException("Bundle not found");
 | 
						|
        }
 | 
						|
 | 
						|
        if (bundle?.ExpiredAt != null)
 | 
						|
            expiredAt = bundle.ExpiredAt.Value;
 | 
						|
 | 
						|
        var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
 | 
						|
        File.Copy(filePath, managedTempPath, true);
 | 
						|
 | 
						|
        var fileInfo = new FileInfo(managedTempPath);
 | 
						|
        var fileSize = fileInfo.Length;
 | 
						|
        var finalContentType = contentType ??
 | 
						|
                               (!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName));
 | 
						|
 | 
						|
        var file = new SnCloudFile
 | 
						|
        {
 | 
						|
            Id = fileId,
 | 
						|
            Name = fileName,
 | 
						|
            MimeType = finalContentType,
 | 
						|
            Size = fileSize,
 | 
						|
            ExpiredAt = expiredAt,
 | 
						|
            BundleId = bundle?.Id,
 | 
						|
            AccountId = Guid.Parse(account.Id),
 | 
						|
        };
 | 
						|
 | 
						|
        if (!pool.PolicyConfig.NoMetadata)
 | 
						|
        {
 | 
						|
            await ExtractMetadataAsync(file, managedTempPath);
 | 
						|
        }
 | 
						|
 | 
						|
        string processingPath = managedTempPath;
 | 
						|
        bool isTempFile = true;
 | 
						|
 | 
						|
        if (!string.IsNullOrWhiteSpace(encryptPassword))
 | 
						|
        {
 | 
						|
            if (!pool.PolicyConfig.AllowEncryption)
 | 
						|
                throw new InvalidOperationException("Encryption is not allowed in this pool");
 | 
						|
 | 
						|
            var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
 | 
						|
            FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword);
 | 
						|
 | 
						|
            File.Delete(managedTempPath);
 | 
						|
 | 
						|
            processingPath = encryptedPath;
 | 
						|
 | 
						|
            file.IsEncrypted = true;
 | 
						|
            file.MimeType = "application/octet-stream";
 | 
						|
            file.Size = new FileInfo(processingPath).Length;
 | 
						|
        }
 | 
						|
 | 
						|
        file.Hash = await HashFileAsync(processingPath);
 | 
						|
 | 
						|
        db.Files.Add(file);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
 | 
						|
        file.StorageId ??= file.Id;
 | 
						|
 | 
						|
        var js = nats.CreateJetStreamContext();
 | 
						|
        await js.PublishAsync(
 | 
						|
            FileUploadedEvent.Type,
 | 
						|
            GrpcTypeHelper.ConvertObjectToByteString(new FileUploadedEventPayload(
 | 
						|
                file.Id,
 | 
						|
                pool.Id,
 | 
						|
                file.StorageId,
 | 
						|
                file.MimeType,
 | 
						|
                processingPath,
 | 
						|
                isTempFile)
 | 
						|
            ).ToByteArray()
 | 
						|
        );
 | 
						|
 | 
						|
        return file;
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
 | 
						|
    {
 | 
						|
        switch (file.MimeType?.Split('/')[0])
 | 
						|
        {
 | 
						|
            case "image":
 | 
						|
                try
 | 
						|
                {
 | 
						|
                    var blurhash = BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(3, 3, filePath);
 | 
						|
                    await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
 | 
						|
                    stream.Position = 0;
 | 
						|
 | 
						|
                    using var vipsImage = Image.NewFromStream(stream);
 | 
						|
                    var width = vipsImage.Width;
 | 
						|
                    var height = vipsImage.Height;
 | 
						|
                    var orientation = 1;
 | 
						|
                    try
 | 
						|
                    {
 | 
						|
                        orientation = vipsImage.Get("orientation") as int? ?? 1;
 | 
						|
                    }
 | 
						|
                    catch
 | 
						|
                    {
 | 
						|
                        // ignored
 | 
						|
                    }
 | 
						|
 | 
						|
                    var meta = new Dictionary<string, object?>
 | 
						|
                    {
 | 
						|
                        ["blur"] = blurhash,
 | 
						|
                        ["format"] = vipsImage.Get("vips-loader") ?? "unknown",
 | 
						|
                        ["width"] = width,
 | 
						|
                        ["height"] = height,
 | 
						|
                        ["orientation"] = orientation,
 | 
						|
                    };
 | 
						|
                    var exif = new Dictionary<string, object>();
 | 
						|
 | 
						|
                    foreach (var field in vipsImage.GetFields())
 | 
						|
                    {
 | 
						|
                        if (IsIgnoredField(field)) continue;
 | 
						|
                        var value = vipsImage.Get(field);
 | 
						|
                        if (field.StartsWith("exif-"))
 | 
						|
                            exif[field.Replace("exif-", "")] = value;
 | 
						|
                        else
 | 
						|
                            meta[field] = value;
 | 
						|
                    }
 | 
						|
 | 
						|
                    if (orientation is 6 or 8) (width, height) = (height, width);
 | 
						|
                    meta["exif"] = exif;
 | 
						|
                    meta["ratio"] = height != 0 ? (double)width / height : 0;
 | 
						|
                    file.FileMeta = meta;
 | 
						|
                }
 | 
						|
                catch (Exception ex)
 | 
						|
                {
 | 
						|
                    file.FileMeta = new Dictionary<string, object?>();
 | 
						|
                    logger.LogError(ex, "Failed to analyze image file {FileId}", file.Id);
 | 
						|
                }
 | 
						|
 | 
						|
                break;
 | 
						|
 | 
						|
            case "video":
 | 
						|
            case "audio":
 | 
						|
                try
 | 
						|
                {
 | 
						|
                    var mediaInfo = await FFProbe.AnalyseAsync(filePath);
 | 
						|
                    file.FileMeta = new Dictionary<string, object?>
 | 
						|
                    {
 | 
						|
                        ["width"] = mediaInfo.PrimaryVideoStream?.Width,
 | 
						|
                        ["height"] = mediaInfo.PrimaryVideoStream?.Height,
 | 
						|
                        ["duration"] = mediaInfo.Duration.TotalSeconds,
 | 
						|
                        ["format_name"] = mediaInfo.Format.FormatName,
 | 
						|
                        ["format_long_name"] = mediaInfo.Format.FormatLongName,
 | 
						|
                        ["start_time"] = mediaInfo.Format.StartTime.ToString(),
 | 
						|
                        ["bit_rate"] = mediaInfo.Format.BitRate.ToString(CultureInfo.InvariantCulture),
 | 
						|
                        ["tags"] = mediaInfo.Format.Tags ?? new Dictionary<string, string>(),
 | 
						|
                        ["chapters"] = mediaInfo.Chapters,
 | 
						|
                        ["video_streams"] = mediaInfo.VideoStreams.Select(s => new
 | 
						|
                        {
 | 
						|
                            s.AvgFrameRate,
 | 
						|
                            s.BitRate,
 | 
						|
                            s.CodecName,
 | 
						|
                            s.Duration,
 | 
						|
                            s.Height,
 | 
						|
                            s.Width,
 | 
						|
                            s.Language,
 | 
						|
                            s.PixelFormat,
 | 
						|
                            s.Rotation
 | 
						|
                        }).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
 | 
						|
                        ["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
 | 
						|
                            {
 | 
						|
                                s.BitRate,
 | 
						|
                                s.Channels,
 | 
						|
                                s.ChannelLayout,
 | 
						|
                                s.CodecName,
 | 
						|
                                s.Duration,
 | 
						|
                                s.Language,
 | 
						|
                                s.SampleRateHz
 | 
						|
                            })
 | 
						|
                            .ToList(),
 | 
						|
                    };
 | 
						|
                    if (mediaInfo.PrimaryVideoStream is not null)
 | 
						|
                        file.FileMeta["ratio"] = (double)mediaInfo.PrimaryVideoStream.Width /
 | 
						|
                                                 mediaInfo.PrimaryVideoStream.Height;
 | 
						|
                }
 | 
						|
                catch (Exception ex)
 | 
						|
                {
 | 
						|
                    logger.LogError(ex, "Failed to analyze media file {FileId}", file.Id);
 | 
						|
                }
 | 
						|
 | 
						|
                break;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private static async Task<string> HashFileAsync(string filePath, int chunkSize = 1024 * 1024)
 | 
						|
    {
 | 
						|
        var fileInfo = new FileInfo(filePath);
 | 
						|
        if (fileInfo.Length > chunkSize * 1024 * 5)
 | 
						|
            return await HashFastApproximateAsync(filePath, chunkSize);
 | 
						|
 | 
						|
        await using var stream = File.OpenRead(filePath);
 | 
						|
        using var md5 = MD5.Create();
 | 
						|
        var hashBytes = await md5.ComputeHashAsync(stream);
 | 
						|
        return Convert.ToHexString(hashBytes).ToLowerInvariant();
 | 
						|
    }
 | 
						|
 | 
						|
    private static async Task<string> HashFastApproximateAsync(string filePath, int chunkSize = 1024 * 1024)
 | 
						|
    {
 | 
						|
        await using var stream = File.OpenRead(filePath);
 | 
						|
 | 
						|
        var buffer = new byte[chunkSize * 2];
 | 
						|
        var fileLength = stream.Length;
 | 
						|
 | 
						|
        var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, chunkSize));
 | 
						|
 | 
						|
        if (fileLength > chunkSize)
 | 
						|
        {
 | 
						|
            stream.Seek(-chunkSize, SeekOrigin.End);
 | 
						|
            bytesRead += await stream.ReadAsync(buffer.AsMemory(chunkSize, chunkSize));
 | 
						|
        }
 | 
						|
 | 
						|
        var hash = MD5.HashData(buffer.AsSpan(0, bytesRead));
 | 
						|
        stream.Position = 0;
 | 
						|
        return Convert.ToHexString(hash).ToLowerInvariant();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task UploadFileToRemoteAsync(
 | 
						|
        string storageId,
 | 
						|
        Guid targetRemote,
 | 
						|
        string filePath,
 | 
						|
        string? suffix = null,
 | 
						|
        string? contentType = null,
 | 
						|
        bool selfDestruct = false
 | 
						|
    )
 | 
						|
    {
 | 
						|
        await using var fileStream = File.OpenRead(filePath);
 | 
						|
        await UploadFileToRemoteAsync(storageId, targetRemote, fileStream, suffix, contentType);
 | 
						|
        if (selfDestruct) File.Delete(filePath);
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task UploadFileToRemoteAsync(
 | 
						|
        string storageId,
 | 
						|
        Guid targetRemote,
 | 
						|
        Stream stream,
 | 
						|
        string? suffix = null,
 | 
						|
        string? contentType = null
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var dest = await GetRemoteStorageConfig(targetRemote);
 | 
						|
        if (dest is null)
 | 
						|
            throw new InvalidOperationException(
 | 
						|
                $"Failed to configure client for remote destination '{targetRemote}'"
 | 
						|
            );
 | 
						|
        var client = CreateMinioClient(dest);
 | 
						|
 | 
						|
        var bucket = dest.Bucket;
 | 
						|
        contentType ??= "application/octet-stream";
 | 
						|
 | 
						|
        await client!.PutObjectAsync(new PutObjectArgs()
 | 
						|
            .WithBucket(bucket)
 | 
						|
            .WithObject(string.IsNullOrWhiteSpace(suffix) ? storageId : storageId + suffix)
 | 
						|
            .WithStreamData(stream)
 | 
						|
            .WithObjectSize(stream.Length)
 | 
						|
            .WithContentType(contentType)
 | 
						|
        );
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SnCloudFile> UpdateFileAsync(SnCloudFile file, FieldMask updateMask)
 | 
						|
    {
 | 
						|
        var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
 | 
						|
        if (existingFile == null)
 | 
						|
        {
 | 
						|
            throw new InvalidOperationException($"File with ID {file.Id} not found.");
 | 
						|
        }
 | 
						|
 | 
						|
        var updatable = new UpdatableCloudFile(existingFile);
 | 
						|
 | 
						|
        foreach (var path in updateMask.Paths)
 | 
						|
        {
 | 
						|
            switch (path)
 | 
						|
            {
 | 
						|
                case "name":
 | 
						|
                    updatable.Name = file.Name;
 | 
						|
                    break;
 | 
						|
                case "description":
 | 
						|
                    updatable.Description = file.Description;
 | 
						|
                    break;
 | 
						|
                case "file_meta":
 | 
						|
                    updatable.FileMeta = file.FileMeta;
 | 
						|
                    break;
 | 
						|
                case "user_meta":
 | 
						|
                    updatable.UserMeta = file.UserMeta;
 | 
						|
                    break;
 | 
						|
                case "is_marked_recycle":
 | 
						|
                    updatable.IsMarkedRecycle = file.IsMarkedRecycle;
 | 
						|
                    break;
 | 
						|
                default:
 | 
						|
                    logger.LogWarning("Attempted to update unmodifiable field: {Field}", path);
 | 
						|
                    break;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(updatable.ToSetPropertyCalls());
 | 
						|
 | 
						|
        await _PurgeCacheAsync(file.Id);
 | 
						|
        return await db.Files.AsNoTracking().FirstAsync(f => f.Id == file.Id);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task DeleteFileAsync(SnCloudFile file)
 | 
						|
    {
 | 
						|
        db.Remove(file);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        await _PurgeCacheAsync(file.Id);
 | 
						|
 | 
						|
        await DeleteFileDataAsync(file);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task DeleteFileDataAsync(SnCloudFile file, bool force = false)
 | 
						|
    {
 | 
						|
        if (!file.PoolId.HasValue) return;
 | 
						|
 | 
						|
        if (!force)
 | 
						|
        {
 | 
						|
            var sameOriginFiles = await db.Files
 | 
						|
                .Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
 | 
						|
                .Select(f => f.Id)
 | 
						|
                .ToListAsync();
 | 
						|
 | 
						|
            if (sameOriginFiles.Count != 0)
 | 
						|
                return;
 | 
						|
        }
 | 
						|
 | 
						|
        var dest = await GetRemoteStorageConfig(file.PoolId.Value);
 | 
						|
        if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
 | 
						|
        var client = CreateMinioClient(dest);
 | 
						|
        if (client is null)
 | 
						|
            throw new InvalidOperationException(
 | 
						|
                $"Failed to configure client for remote destination '{file.PoolId}'"
 | 
						|
            );
 | 
						|
 | 
						|
        var bucket = dest.Bucket;
 | 
						|
        var objectId = file.StorageId ?? file.Id;
 | 
						|
 | 
						|
        await client.RemoveObjectAsync(
 | 
						|
            new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
 | 
						|
        );
 | 
						|
 | 
						|
        if (file.HasCompression)
 | 
						|
        {
 | 
						|
            try
 | 
						|
            {
 | 
						|
                await client.RemoveObjectAsync(
 | 
						|
                    new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".compressed")
 | 
						|
                );
 | 
						|
            }
 | 
						|
            catch
 | 
						|
            {
 | 
						|
                logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (file.HasThumbnail)
 | 
						|
        {
 | 
						|
            try
 | 
						|
            {
 | 
						|
                await client.RemoveObjectAsync(
 | 
						|
                    new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".thumbnail")
 | 
						|
                );
 | 
						|
            }
 | 
						|
            catch
 | 
						|
            {
 | 
						|
                logger.LogWarning("Failed to delete thumbnail of file {fileId}", file.Id);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task DeleteFileDataBatchAsync(List<SnCloudFile> files)
 | 
						|
    {
 | 
						|
        files = files.Where(f => f.PoolId.HasValue).ToList();
 | 
						|
 | 
						|
        foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
 | 
						|
        {
 | 
						|
            var dest = await GetRemoteStorageConfig(fileGroup.Key);
 | 
						|
            if (dest is null)
 | 
						|
                throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
 | 
						|
            var client = CreateMinioClient(dest);
 | 
						|
            if (client is null)
 | 
						|
                throw new InvalidOperationException(
 | 
						|
                    $"Failed to configure client for remote destination '{fileGroup.Key}'"
 | 
						|
                );
 | 
						|
 | 
						|
            List<string> objectsToDelete = [];
 | 
						|
 | 
						|
            foreach (var file in fileGroup)
 | 
						|
            {
 | 
						|
                objectsToDelete.Add(file.StorageId ?? file.Id);
 | 
						|
                if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
 | 
						|
                if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
 | 
						|
            }
 | 
						|
 | 
						|
            await client.RemoveObjectsAsync(
 | 
						|
                new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
 | 
						|
            );
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<SnFileBundle?> GetBundleAsync(Guid id, Guid accountId)
 | 
						|
    {
 | 
						|
        var bundle = await db.Bundles
 | 
						|
            .Where(e => e.Id == id)
 | 
						|
            .Where(e => e.AccountId == accountId)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        return bundle;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<FilePool?> GetPoolAsync(Guid destination)
 | 
						|
    {
 | 
						|
        var cacheKey = $"file:pool:{destination}";
 | 
						|
        var cachedResult = await cache.GetAsync<FilePool?>(cacheKey);
 | 
						|
        if (cachedResult != null) return cachedResult;
 | 
						|
 | 
						|
        var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == destination);
 | 
						|
        if (pool != null)
 | 
						|
            await cache.SetAsync(cacheKey, pool);
 | 
						|
 | 
						|
        return pool;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<RemoteStorageConfig?> GetRemoteStorageConfig(Guid destination)
 | 
						|
    {
 | 
						|
        var pool = await GetPoolAsync(destination);
 | 
						|
        return pool?.StorageConfig;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<RemoteStorageConfig?> GetRemoteStorageConfig(string destination)
 | 
						|
    {
 | 
						|
        var id = Guid.Parse(destination);
 | 
						|
        return await GetRemoteStorageConfig(id);
 | 
						|
    }
 | 
						|
 | 
						|
    public IMinioClient? CreateMinioClient(RemoteStorageConfig dest)
 | 
						|
    {
 | 
						|
        var client = new MinioClient()
 | 
						|
            .WithEndpoint(dest.Endpoint)
 | 
						|
            .WithRegion(dest.Region)
 | 
						|
            .WithCredentials(dest.SecretId, dest.SecretKey);
 | 
						|
        if (dest.EnableSsl) client = client.WithSSL();
 | 
						|
 | 
						|
        return client.Build();
 | 
						|
    }
 | 
						|
 | 
						|
    internal async Task _PurgeCacheAsync(string fileId)
 | 
						|
    {
 | 
						|
        var cacheKey = $"{CacheKeyPrefix}{fileId}";
 | 
						|
        await cache.RemoveAsync(cacheKey);
 | 
						|
    }
 | 
						|
 | 
						|
    internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
 | 
						|
    {
 | 
						|
        var tasks = fileIds.Select(_PurgeCacheAsync);
 | 
						|
        await Task.WhenAll(tasks);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<List<SnCloudFile?>> LoadFromReference(List<SnCloudFileReferenceObject> references)
 | 
						|
    {
 | 
						|
        var cachedFiles = new Dictionary<string, SnCloudFile>();
 | 
						|
        var uncachedIds = new List<string>();
 | 
						|
 | 
						|
        foreach (var reference in references)
 | 
						|
        {
 | 
						|
            var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
 | 
						|
            var cachedFile = await cache.GetAsync<SnCloudFile>(cacheKey);
 | 
						|
 | 
						|
            if (cachedFile != null)
 | 
						|
            {
 | 
						|
                cachedFiles[reference.Id] = cachedFile;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                uncachedIds.Add(reference.Id);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (uncachedIds.Count > 0)
 | 
						|
        {
 | 
						|
            var dbFiles = await db.Files
 | 
						|
                .Where(f => uncachedIds.Contains(f.Id))
 | 
						|
                .ToListAsync();
 | 
						|
 | 
						|
            foreach (var file in dbFiles)
 | 
						|
            {
 | 
						|
                var cacheKey = $"{CacheKeyPrefix}{file.Id}";
 | 
						|
                await cache.SetAsync(cacheKey, file, CacheDuration);
 | 
						|
                cachedFiles[file.Id] = file;
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return [.. references
 | 
						|
            .Select(r => cachedFiles.GetValueOrDefault(r.Id))
 | 
						|
            .Where(f => f != null)];
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<int> GetReferenceCountAsync(string fileId)
 | 
						|
    {
 | 
						|
        return await db.FileReferences
 | 
						|
            .Where(r => r.FileId == fileId)
 | 
						|
            .CountAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<bool> IsReferencedAsync(string fileId)
 | 
						|
    {
 | 
						|
        return await db.FileReferences
 | 
						|
            .Where(r => r.FileId == fileId)
 | 
						|
            .AnyAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    private static bool IsIgnoredField(string fieldName)
 | 
						|
    {
 | 
						|
        var gpsFields = new[]
 | 
						|
        {
 | 
						|
            "gps-latitude", "gps-longitude", "gps-altitude", "gps-latitude-ref", "gps-longitude-ref",
 | 
						|
            "gps-altitude-ref", "gps-timestamp", "gps-datestamp", "gps-speed", "gps-speed-ref", "gps-track",
 | 
						|
            "gps-track-ref", "gps-img-direction", "gps-img-direction-ref", "gps-dest-latitude",
 | 
						|
            "gps-dest-longitude", "gps-dest-latitude-ref", "gps-dest-longitude-ref", "gps-processing-method",
 | 
						|
            "gps-area-information"
 | 
						|
        };
 | 
						|
 | 
						|
        if (fieldName.StartsWith("exif-GPS")) return true;
 | 
						|
        if (fieldName.StartsWith("ifd3-GPS")) return true;
 | 
						|
        if (fieldName.EndsWith("-data")) return true;
 | 
						|
        return gpsFields.Any(gpsField => fieldName.StartsWith(gpsField, StringComparison.OrdinalIgnoreCase));
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<int> DeleteAccountRecycledFilesAsync(Guid accountId)
 | 
						|
    {
 | 
						|
        var files = await db.Files
 | 
						|
            .Where(f => f.AccountId == accountId && f.IsMarkedRecycle)
 | 
						|
            .ToListAsync();
 | 
						|
        var count = files.Count;
 | 
						|
        var tasks = files.Select(f => DeleteFileDataAsync(f, true));
 | 
						|
        await Task.WhenAll(tasks);
 | 
						|
        var fileIds = files.Select(f => f.Id).ToList();
 | 
						|
        await _PurgeCacheRangeAsync(fileIds);
 | 
						|
        db.RemoveRange(files);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        return count;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<int> DeletePoolRecycledFilesAsync(Guid poolId)
 | 
						|
    {
 | 
						|
        var files = await db.Files
 | 
						|
            .Where(f => f.PoolId == poolId && f.IsMarkedRecycle)
 | 
						|
            .ToListAsync();
 | 
						|
        var count = files.Count;
 | 
						|
        var tasks = files.Select(f => DeleteFileDataAsync(f, true));
 | 
						|
        await Task.WhenAll(tasks);
 | 
						|
        var fileIds = files.Select(f => f.Id).ToList();
 | 
						|
        await _PurgeCacheRangeAsync(fileIds);
 | 
						|
        db.RemoveRange(files);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        return count;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<int> DeleteAllRecycledFilesAsync()
 | 
						|
    {
 | 
						|
        var files = await db.Files
 | 
						|
            .Where(f => f.IsMarkedRecycle)
 | 
						|
            .ToListAsync();
 | 
						|
        var count = files.Count;
 | 
						|
        var tasks = files.Select(f => DeleteFileDataAsync(f, true));
 | 
						|
        await Task.WhenAll(tasks);
 | 
						|
        var fileIds = files.Select(f => f.Id).ToList();
 | 
						|
        await _PurgeCacheRangeAsync(fileIds);
 | 
						|
        db.RemoveRange(files);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        return count;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<string> CreateFastUploadLinkAsync(SnCloudFile file)
 | 
						|
    {
 | 
						|
        if (file.PoolId is null) throw new InvalidOperationException("Pool ID is null");
 | 
						|
 | 
						|
        var dest = await GetRemoteStorageConfig(file.PoolId.Value);
 | 
						|
        if (dest is null) throw new InvalidOperationException($"No remote storage configured for pool {file.PoolId}");
 | 
						|
        var client = CreateMinioClient(dest);
 | 
						|
        if (client is null)
 | 
						|
            throw new InvalidOperationException(
 | 
						|
                $"Failed to configure client for remote destination '{file.PoolId}'"
 | 
						|
            );
 | 
						|
 | 
						|
        var url = await client.PresignedPutObjectAsync(
 | 
						|
            new PresignedPutObjectArgs()
 | 
						|
                .WithBucket(dest.Bucket)
 | 
						|
                .WithObject(file.Id)
 | 
						|
                .WithExpiry(60 * 60 * 24)
 | 
						|
        );
 | 
						|
        return url;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
file class UpdatableCloudFile(SnCloudFile file)
 | 
						|
{
 | 
						|
    public string Name { get; set; } = file.Name;
 | 
						|
    public string? Description { get; set; } = file.Description;
 | 
						|
    public Dictionary<string, object?>? FileMeta { get; set; } = file.FileMeta;
 | 
						|
    public Dictionary<string, object?>? UserMeta { get; set; } = file.UserMeta;
 | 
						|
    public bool IsMarkedRecycle { get; set; } = file.IsMarkedRecycle;
 | 
						|
 | 
						|
    public Expression<Func<SetPropertyCalls<SnCloudFile>, SetPropertyCalls<SnCloudFile>>> ToSetPropertyCalls()
 | 
						|
    {
 | 
						|
        var userMeta = UserMeta ?? [];
 | 
						|
        return setter => setter
 | 
						|
            .SetProperty(f => f.Name, Name)
 | 
						|
            .SetProperty(f => f.Description, Description)
 | 
						|
            .SetProperty(f => f.FileMeta, FileMeta)
 | 
						|
            .SetProperty(f => f.UserMeta, userMeta)
 | 
						|
            .SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
 | 
						|
    }
 | 
						|
} |