💥 ♻️ Refactor cloud files' references, and loading system
This commit is contained in:
@ -215,6 +215,7 @@ public class CacheServiceRedis : ICacheService
|
||||
|
||||
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
|
||||
@ -224,6 +225,7 @@ public class CacheServiceRedis : ICacheService
|
||||
|
||||
public async Task<T?> GetAsync<T>(string key)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
|
||||
@ -238,6 +240,7 @@ public class CacheServiceRedis : ICacheService
|
||||
|
||||
public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
|
||||
@ -252,6 +255,7 @@ public class CacheServiceRedis : ICacheService
|
||||
|
||||
public async Task<bool> RemoveAsync(string key)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||
|
||||
@ -281,6 +285,7 @@ public class CacheServiceRedis : ICacheService
|
||||
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||
|
||||
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
await _database.SetAddAsync(groupKey, key);
|
||||
}
|
||||
|
||||
@ -319,6 +324,7 @@ public class CacheServiceRedis : ICacheService
|
||||
public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null,
|
||||
TimeSpan? expiry = null)
|
||||
{
|
||||
key = $"{GlobalKeyPrefix}{key}";
|
||||
// First, set the value in the cache
|
||||
var setResult = await SetAsync(key, value, expiry);
|
||||
|
||||
|
@ -20,10 +20,28 @@ public class RemoteStorageConfig
|
||||
public string? AccessProxy { get; set; }
|
||||
}
|
||||
|
||||
public class CloudFile : ModelBase
|
||||
/// <summary>
|
||||
/// The class that used in jsonb columns which referenced the cloud file.
|
||||
/// The aim of this class is to store some properties that won't change to a file to reduce the database load.
|
||||
/// </summary>
|
||||
public class CloudFileReferenceObject : ICloudFile
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public Dictionary<string, object>? FileMeta { get; set; } = null!;
|
||||
public Dictionary<string, object>? UserMeta { get; set; } = null!;
|
||||
public string? MimeType { get; set; }
|
||||
public string? Hash { get; set; }
|
||||
public long Size { get; set; }
|
||||
public bool HasCompression { get; set; } = false;
|
||||
}
|
||||
|
||||
public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
{
|
||||
/// The id generated by TuS, basically just UUID remove the dash lines
|
||||
[MaxLength(32)] public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
[MaxLength(32)]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? FileMeta { get; set; } = null!;
|
||||
@ -33,29 +51,42 @@ public class CloudFile : ModelBase
|
||||
[MaxLength(256)] public string? Hash { get; set; }
|
||||
public long Size { get; set; }
|
||||
public Instant? UploadedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
[MaxLength(128)] public string? UploadedTo { get; set; }
|
||||
public bool HasCompression { get; set; }= false;
|
||||
|
||||
public bool HasCompression { get; set; } = false;
|
||||
|
||||
/// The object name which stored remotely,
|
||||
/// multiple cloud file may have same storage id to indicate they are the same file
|
||||
///
|
||||
/// If the storage id was null and the uploaded at is not null, means it is an embedding file,
|
||||
/// The embedding file means the file is store on another site,
|
||||
/// or it is a webpage (based on mimetype)
|
||||
[MaxLength(32)] public string? StorageId { get; set; }
|
||||
[MaxLength(32)]
|
||||
public string? StorageId { get; set; }
|
||||
|
||||
/// This field should be null when the storage id is filled
|
||||
/// Indicates the off-site accessible url of the file
|
||||
[MaxLength(4096)] public string? StorageUrl { get; set; }
|
||||
|
||||
/// Metrics
|
||||
/// When this used count keep zero, it means it's not used by anybody, so it can be recycled
|
||||
public int UsedCount { get; set; } = 0;
|
||||
/// An optional package identifier that indicates the cloud file's usage
|
||||
[MaxLength(1024)] public string? Usage { get; set; }
|
||||
[MaxLength(4096)]
|
||||
public string? StorageUrl { get; set; }
|
||||
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public CloudFileReferenceObject ToReferenceObject()
|
||||
{
|
||||
return new CloudFileReferenceObject
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
FileMeta = FileMeta,
|
||||
UserMeta = UserMeta,
|
||||
MimeType = MimeType,
|
||||
Hash = Hash,
|
||||
Size = Size,
|
||||
HasCompression = HasCompression
|
||||
};
|
||||
}
|
||||
|
||||
public string ResourceIdentifier => $"file/{Id}";
|
||||
}
|
||||
|
||||
public enum CloudFileSensitiveMark
|
||||
@ -73,4 +104,18 @@ public enum CloudFileSensitiveMark
|
||||
SelfHarm,
|
||||
ChildAbuse,
|
||||
Other
|
||||
}
|
||||
|
||||
public class CloudFileReference : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(32)] public string FileId { get; set; } = null!;
|
||||
public CloudFile File { get; set; } = null!;
|
||||
[MaxLength(1024)] public string Usage { get; set; } = null!;
|
||||
[MaxLength(1024)] public string ResourceId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiration date for the file reference
|
||||
/// </summary>
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
127
DysonNetwork.Sphere/Storage/CloudFileUnusedRecyclingJob.cs
Normal file
127
DysonNetwork.Sphere/Storage/CloudFileUnusedRecyclingJob.cs
Normal file
@ -0,0 +1,127 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Sphere.Storage;
|
||||
|
||||
public class CloudFileUnusedRecyclingJob(
|
||||
AppDatabase db,
|
||||
FileService fs,
|
||||
FileReferenceService fileRefService,
|
||||
ILogger<CloudFileUnusedRecyclingJob> logger
|
||||
)
|
||||
: IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Deleting unused cloud files...");
|
||||
|
||||
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Get files that are either expired or created more than an hour ago
|
||||
var fileIds = await db.Files
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// Filter to only include files that have no references or all references have expired
|
||||
var deletionPlan = new List<string>();
|
||||
foreach (var batch in fileIds.Chunk(100)) // Process in batches to avoid excessive query size
|
||||
{
|
||||
var references = await fileRefService.GetReferencesAsync(batch);
|
||||
deletionPlan.AddRange(from refer in references
|
||||
where refer.Value.Count == 0 || refer.Value.All(r => r.ExpiredAt != null && now >= r.ExpiredAt)
|
||||
select refer.Key);
|
||||
}
|
||||
|
||||
if (deletionPlan.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No files to delete");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual file objects for the files to be deleted
|
||||
var files = await db.Files
|
||||
.Where(f => deletionPlan.Contains(f.Id))
|
||||
.ToListAsync();
|
||||
|
||||
logger.LogInformation($"Found {files.Count} files to delete...");
|
||||
|
||||
// Group files by StorageId and find which ones are safe to delete
|
||||
var storageIds = files.Where(f => f.StorageId != null)
|
||||
.Select(f => f.StorageId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// Check if any other files with the same storage IDs are referenced
|
||||
var usedStorageIds = new List<string>();
|
||||
var filesWithSameStorageId = await db.Files
|
||||
.Where(f => f.StorageId != null &&
|
||||
storageIds.Contains(f.StorageId) &&
|
||||
!files.Select(ff => ff.Id).Contains(f.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var file in filesWithSameStorageId)
|
||||
{
|
||||
// Get all references for the file
|
||||
var references = await fileRefService.GetReferencesAsync(file.Id);
|
||||
|
||||
// Check if file has active references (non-expired)
|
||||
if (references.Any(r => r.ExpiredAt == null || r.ExpiredAt > now) && file.StorageId != null)
|
||||
{
|
||||
usedStorageIds.Add(file.StorageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Group files for deletion
|
||||
var filesToDelete = files.Where(f => f.StorageId == null || !usedStorageIds.Contains(f.StorageId))
|
||||
.GroupBy(f => f.UploadedTo)
|
||||
.ToDictionary(grouping => grouping.Key!, grouping => grouping.ToList());
|
||||
|
||||
// Delete files by remote storage
|
||||
foreach (var group in filesToDelete.Where(group => !string.IsNullOrEmpty(group.Key)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dest = fs.GetRemoteStorageConfig(group.Key);
|
||||
var client = fs.CreateMinioClient(dest);
|
||||
if (client == null) continue;
|
||||
|
||||
// Create delete tasks for each file in the group
|
||||
var deleteTasks = group.Value.Select(file =>
|
||||
{
|
||||
var objectId = file.StorageId ?? file.Id;
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
client.RemoveObjectAsync(new Minio.DataModel.Args.RemoveObjectArgs()
|
||||
.WithBucket(dest.Bucket)
|
||||
.WithObject(objectId))
|
||||
};
|
||||
|
||||
if (file.HasCompression)
|
||||
{
|
||||
tasks.Add(client.RemoveObjectAsync(new Minio.DataModel.Args.RemoveObjectArgs()
|
||||
.WithBucket(dest.Bucket)
|
||||
.WithObject(objectId + ".compressed")));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
});
|
||||
|
||||
await Task.WhenAll(deleteTasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting files from remote storage {remote}", group.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all file records from the database
|
||||
var fileIdsToDelete = files.Select(f => f.Id).ToList();
|
||||
await db.Files
|
||||
.Where(f => fileIdsToDelete.Contains(f.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
logger.LogInformation($"Completed deleting {files.Count} files");
|
||||
}
|
||||
}
|
66
DysonNetwork.Sphere/Storage/FileExpirationJob.cs
Normal file
66
DysonNetwork.Sphere/Storage/FileExpirationJob.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Sphere.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Job responsible for cleaning up expired file references
|
||||
/// </summary>
|
||||
public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<FileExpirationJob> logger) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
logger.LogInformation("Running file reference expiration job at {now}", now);
|
||||
|
||||
// Find all expired references
|
||||
var expiredReferences = await db.FileReferences
|
||||
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
|
||||
.ToListAsync();
|
||||
|
||||
if (!expiredReferences.Any())
|
||||
{
|
||||
logger.LogInformation("No expired file references found");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Found {count} expired file references", expiredReferences.Count);
|
||||
|
||||
// Get unique file IDs
|
||||
var fileIds = expiredReferences.Select(r => r.FileId).Distinct().ToList();
|
||||
var filesAndReferenceCount = new Dictionary<string, int>();
|
||||
|
||||
// Delete expired references
|
||||
db.FileReferences.RemoveRange(expiredReferences);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Check remaining references for each file
|
||||
foreach (var fileId in fileIds)
|
||||
{
|
||||
var remainingReferences = await db.FileReferences
|
||||
.Where(r => r.FileId == fileId)
|
||||
.CountAsync();
|
||||
|
||||
filesAndReferenceCount[fileId] = remainingReferences;
|
||||
|
||||
// If no references remain, delete the file
|
||||
if (remainingReferences == 0)
|
||||
{
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId);
|
||||
if (file != null)
|
||||
{
|
||||
logger.LogInformation("Deleting file {fileId} as all references have expired", fileId);
|
||||
await fileService.DeleteFileAsync(file);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Just purge the cache
|
||||
await fileService._PurgeCacheAsync(fileId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Completed file reference expiration job");
|
||||
}
|
||||
}
|
407
DysonNetwork.Sphere/Storage/FileReferenceService.cs
Normal file
407
DysonNetwork.Sphere/Storage/FileReferenceService.cs
Normal file
@ -0,0 +1,407 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Storage;
|
||||
|
||||
public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache)
|
||||
{
|
||||
private const string CacheKeyPrefix = "fileref:";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new reference to a file for a specific resource
|
||||
/// </summary>
|
||||
/// <param name="fileId">The ID of the file to reference</param>
|
||||
/// <param name="usage">The usage context (e.g., "avatar", "post-attachment")</param>
|
||||
/// <param name="resourceId">The ID of the resource using the file</param>
|
||||
/// <param name="expiredAt">Optional expiration time for the file</param>
|
||||
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
|
||||
/// <returns>The created file reference</returns>
|
||||
public async Task<CloudFileReference> CreateReferenceAsync(
|
||||
string fileId,
|
||||
string usage,
|
||||
string resourceId,
|
||||
Instant? expiredAt = null,
|
||||
Duration? duration = null)
|
||||
{
|
||||
// Calculate expiration time if needed
|
||||
Instant? finalExpiration = expiredAt;
|
||||
if (duration.HasValue)
|
||||
{
|
||||
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||
}
|
||||
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = fileId,
|
||||
Usage = usage,
|
||||
ResourceId = resourceId,
|
||||
ExpiredAt = finalExpiration
|
||||
};
|
||||
|
||||
db.FileReferences.Add(reference);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Purge cache for the file since its usage count has effectively changed
|
||||
await fileService._PurgeCacheAsync(fileId);
|
||||
|
||||
return reference;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all references to a file
|
||||
/// </summary>
|
||||
/// <param name="fileId">The ID of the file</param>
|
||||
/// <returns>A list of all references to the file</returns>
|
||||
public async Task<List<CloudFileReference>> GetReferencesAsync(string fileId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
|
||||
|
||||
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
|
||||
if (cachedReferences is not null)
|
||||
return cachedReferences;
|
||||
|
||||
var references = await db.FileReferences
|
||||
.Where(r => r.FileId == fileId)
|
||||
.ToListAsync();
|
||||
|
||||
await cache.SetAsync(cacheKey, references, CacheDuration);
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, List<CloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileId)
|
||||
{
|
||||
var references = await db.FileReferences
|
||||
.Where(r => fileId.Contains(r.FileId))
|
||||
.GroupBy(r => r.FileId)
|
||||
.ToDictionaryAsync(r => r.Key, r => r.ToList());
|
||||
return references;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of references to a file
|
||||
/// </summary>
|
||||
/// <param name="fileId">The ID of the file</param>
|
||||
/// <returns>The number of references to the file</returns>
|
||||
public async Task<int> GetReferenceCountAsync(string fileId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}count:{fileId}";
|
||||
|
||||
var cachedCount = await cache.GetAsync<int?>(cacheKey);
|
||||
if (cachedCount.HasValue)
|
||||
return cachedCount.Value;
|
||||
|
||||
var count = await db.FileReferences
|
||||
.Where(r => r.FileId == fileId)
|
||||
.CountAsync();
|
||||
|
||||
await cache.SetAsync(cacheKey, count, CacheDuration);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all references for a specific resource
|
||||
/// </summary>
|
||||
/// <param name="resourceId">The ID of the resource</param>
|
||||
/// <returns>A list of file references associated with the resource</returns>
|
||||
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
|
||||
|
||||
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
|
||||
if (cachedReferences is not null)
|
||||
return cachedReferences;
|
||||
|
||||
var references = await db.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId)
|
||||
.ToListAsync();
|
||||
|
||||
await cache.SetAsync(cacheKey, references, CacheDuration);
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all file references for a specific usage context
|
||||
/// </summary>
|
||||
/// <param name="usage">The usage context</param>
|
||||
/// <returns>A list of file references with the specified usage</returns>
|
||||
public async Task<List<CloudFileReference>> GetUsageReferencesAsync(string usage)
|
||||
{
|
||||
return await db.FileReferences
|
||||
.Where(r => r.Usage == usage)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes references for a specific resource
|
||||
/// </summary>
|
||||
/// <param name="resourceId">The ID of the resource</param>
|
||||
/// <returns>The number of deleted references</returns>
|
||||
public async Task<int> DeleteResourceReferencesAsync(string resourceId)
|
||||
{
|
||||
var references = await db.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId)
|
||||
.ToListAsync();
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
db.FileReferences.RemoveRange(references);
|
||||
var deletedCount = await db.SaveChangesAsync();
|
||||
|
||||
// Purge caches
|
||||
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||
tasks.Add(PurgeCacheForResourceAsync(resourceId));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific file reference
|
||||
/// </summary>
|
||||
/// <param name="referenceId">The ID of the reference to delete</param>
|
||||
/// <returns>True if the reference was deleted, false otherwise</returns>
|
||||
public async Task<bool> DeleteReferenceAsync(Guid referenceId)
|
||||
{
|
||||
var reference = await db.FileReferences
|
||||
.FirstOrDefaultAsync(r => r.Id == referenceId);
|
||||
|
||||
if (reference == null)
|
||||
return false;
|
||||
|
||||
db.FileReferences.Remove(reference);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Purge caches
|
||||
await fileService._PurgeCacheAsync(reference.FileId);
|
||||
await PurgeCacheForResourceAsync(reference.ResourceId);
|
||||
await PurgeCacheForFileAsync(reference.FileId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the files referenced by a resource
|
||||
/// </summary>
|
||||
/// <param name="resourceId">The ID of the resource</param>
|
||||
/// <param name="newFileIds">The new list of file IDs</param>
|
||||
/// <param name="usage">The usage context</param>
|
||||
/// <param name="expiredAt">Optional expiration time for newly added files</param>
|
||||
/// <param name="duration">Optional duration after which newly added files expire</param>
|
||||
/// <returns>A list of the updated file references</returns>
|
||||
public async Task<List<CloudFileReference>> UpdateResourceFilesAsync(
|
||||
string resourceId,
|
||||
IEnumerable<string>? newFileIds,
|
||||
string usage,
|
||||
Instant? expiredAt = null,
|
||||
Duration? duration = null)
|
||||
{
|
||||
if (newFileIds == null)
|
||||
return new List<CloudFileReference>();
|
||||
|
||||
var existingReferences = await db.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||
.ToListAsync();
|
||||
|
||||
var existingFileIds = existingReferences.Select(r => r.FileId).ToHashSet();
|
||||
var newFileIdsList = newFileIds.ToList();
|
||||
var newFileIdsSet = newFileIdsList.ToHashSet();
|
||||
|
||||
// Files to remove
|
||||
var toRemove = existingReferences
|
||||
.Where(r => !newFileIdsSet.Contains(r.FileId))
|
||||
.ToList();
|
||||
|
||||
// Files to add
|
||||
var toAdd = newFileIdsList
|
||||
.Where(id => !existingFileIds.Contains(id))
|
||||
.Select(id => new CloudFileReference
|
||||
{
|
||||
FileId = id,
|
||||
Usage = usage,
|
||||
ResourceId = resourceId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Apply changes
|
||||
if (toRemove.Any())
|
||||
db.FileReferences.RemoveRange(toRemove);
|
||||
|
||||
if (toAdd.Any())
|
||||
db.FileReferences.AddRange(toAdd);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Update expiration for newly added references if specified
|
||||
if ((expiredAt.HasValue || duration.HasValue) && toAdd.Any())
|
||||
{
|
||||
var finalExpiration = expiredAt;
|
||||
if (duration.HasValue)
|
||||
{
|
||||
finalExpiration = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||
}
|
||||
|
||||
// Update newly added references with the expiration time
|
||||
var referenceIds = await db.FileReferences
|
||||
.Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) &&
|
||||
r.ResourceId == resourceId &&
|
||||
r.Usage == usage)
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await db.FileReferences
|
||||
.Where(r => referenceIds.Contains(r.Id))
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
r => r.ExpiredAt,
|
||||
_ => finalExpiration
|
||||
));
|
||||
}
|
||||
|
||||
// Purge caches
|
||||
var allFileIds = existingFileIds.Union(newFileIdsSet).ToList();
|
||||
var tasks = allFileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||
tasks.Add(PurgeCacheForResourceAsync(resourceId));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Return updated references
|
||||
return await db.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all files referenced by a resource
|
||||
/// </summary>
|
||||
/// <param name="resourceId">The ID of the resource</param>
|
||||
/// <param name="usage">Optional filter by usage context</param>
|
||||
/// <returns>A list of files referenced by the resource</returns>
|
||||
public async Task<List<CloudFile>> GetResourceFilesAsync(string resourceId, string? usage = null)
|
||||
{
|
||||
var query = db.FileReferences.Where(r => r.ResourceId == resourceId);
|
||||
|
||||
if (usage != null)
|
||||
query = query.Where(r => r.Usage == usage);
|
||||
|
||||
var references = await query.ToListAsync();
|
||||
var fileIds = references.Select(r => r.FileId).ToList();
|
||||
|
||||
return await db.Files
|
||||
.Where(f => fileIds.Contains(f.Id))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purges all caches related to a resource
|
||||
/// </summary>
|
||||
private async Task PurgeCacheForResourceAsync(string resourceId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}resource:{resourceId}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purges all caches related to a file
|
||||
/// </summary>
|
||||
private async Task PurgeCacheForFileAsync(string fileId)
|
||||
{
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
$"{CacheKeyPrefix}list:{fileId}",
|
||||
$"{CacheKeyPrefix}count:{fileId}"
|
||||
};
|
||||
|
||||
var tasks = cacheKeys.Select(cache.RemoveAsync);
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the expiration time for a file reference
|
||||
/// </summary>
|
||||
/// <param name="referenceId">The ID of the reference</param>
|
||||
/// <param name="expiredAt">The new expiration time, or null to remove expiration</param>
|
||||
/// <returns>True if the reference was found and updated, false otherwise</returns>
|
||||
public async Task<bool> SetReferenceExpirationAsync(Guid referenceId, Instant? expiredAt)
|
||||
{
|
||||
var reference = await db.FileReferences
|
||||
.FirstOrDefaultAsync(r => r.Id == referenceId);
|
||||
|
||||
if (reference == null)
|
||||
return false;
|
||||
|
||||
reference.ExpiredAt = expiredAt;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PurgeCacheForFileAsync(reference.FileId);
|
||||
await PurgeCacheForResourceAsync(reference.ResourceId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the expiration time for all references to a file
|
||||
/// </summary>
|
||||
/// <param name="fileId">The ID of the file</param>
|
||||
/// <param name="expiredAt">The new expiration time, or null to remove expiration</param>
|
||||
/// <returns>The number of references updated</returns>
|
||||
public async Task<int> SetFileReferencesExpirationAsync(string fileId, Instant? expiredAt)
|
||||
{
|
||||
var rowsAffected = await db.FileReferences
|
||||
.Where(r => r.FileId == fileId)
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
r => r.ExpiredAt,
|
||||
_ => expiredAt
|
||||
));
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
await fileService._PurgeCacheAsync(fileId);
|
||||
await PurgeCacheForFileAsync(fileId);
|
||||
}
|
||||
|
||||
return rowsAffected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all file references for a specific resource and usage type
|
||||
/// </summary>
|
||||
/// <param name="resourceId">The resource ID</param>
|
||||
/// <param name="usageType">The usage type</param>
|
||||
/// <returns>List of file references</returns>
|
||||
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string usageType)
|
||||
{
|
||||
return await db.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usageType)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a file has any references
|
||||
/// </summary>
|
||||
/// <param name="fileId">The file ID to check</param>
|
||||
/// <returns>True if the file has references, false otherwise</returns>
|
||||
public async Task<bool> HasFileReferencesAsync(string fileId)
|
||||
{
|
||||
return await db.FileReferences.AnyAsync(r => r.FileId == fileId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the expiration time for a file reference using a duration from now
|
||||
/// </summary>
|
||||
/// <param name="referenceId">The ID of the reference</param>
|
||||
/// <param name="duration">The duration after which the reference expires, or null to remove expiration</param>
|
||||
/// <returns>True if the reference was found and updated, false otherwise</returns>
|
||||
public async Task<bool> SetReferenceExpirationDurationAsync(Guid referenceId, Duration? duration)
|
||||
{
|
||||
Instant? expiredAt = null;
|
||||
if (duration.HasValue)
|
||||
{
|
||||
expiredAt = SystemClock.Instance.GetCurrentInstant() + duration.Value;
|
||||
}
|
||||
|
||||
return await SetReferenceExpirationAsync(referenceId, expiredAt);
|
||||
}
|
||||
}
|
@ -39,7 +39,10 @@ public class FileService(
|
||||
if (cachedFile is not null)
|
||||
return cachedFile;
|
||||
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId);
|
||||
var file = await db.Files
|
||||
.Include(f => f.Account)
|
||||
.Where(f => f.Id == fileId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (file != null)
|
||||
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||
@ -208,8 +211,9 @@ public class FileService(
|
||||
if (result.Count > 0)
|
||||
{
|
||||
List<Task<CloudFile>> tasks = [];
|
||||
tasks.AddRange(result.Select(result =>
|
||||
nfs.UploadFileToRemoteAsync(file, result.filePath, null, result.suffix, true)));
|
||||
tasks.AddRange(result.Select(item =>
|
||||
nfs.UploadFileToRemoteAsync(file, item.filePath, null, item.suffix, true))
|
||||
);
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
file = await tasks.First();
|
||||
@ -326,10 +330,23 @@ public class FileService(
|
||||
if (file.StorageId is null) return;
|
||||
if (file.UploadedTo is null) return;
|
||||
|
||||
var repeatedStorageId = await db.Files
|
||||
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id && f.UsedCount > 0)
|
||||
.AnyAsync();
|
||||
if (repeatedStorageId) return;
|
||||
// Check if any other file with the same storage ID is referenced
|
||||
var otherFilesWithSameStorageId = await db.Files
|
||||
.Where(f => f.StorageId == file.StorageId && f.Id != file.Id)
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// Check if any of these files are referenced
|
||||
var anyReferenced = false;
|
||||
if (otherFilesWithSameStorageId.Any())
|
||||
{
|
||||
anyReferenced = await db.FileReferences
|
||||
.Where(r => otherFilesWithSameStorageId.Contains(r.FileId))
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
||||
if (anyReferenced) return;
|
||||
|
||||
var dest = GetRemoteStorageConfig(file.UploadedTo);
|
||||
var client = CreateMinioClient(dest);
|
||||
@ -380,242 +397,88 @@ public class FileService(
|
||||
|
||||
return client.Build();
|
||||
}
|
||||
|
||||
public async Task MarkUsageAsync(CloudFile file, int delta)
|
||||
{
|
||||
await db.Files.Where(o => o.Id == file.Id)
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
b => b.UsedCount,
|
||||
b => b.UsedCount + delta
|
||||
)
|
||||
);
|
||||
|
||||
await _PurgeCacheAsync(file.Id);
|
||||
}
|
||||
|
||||
public async Task MarkUsageRangeAsync(ICollection<CloudFile> files, int delta)
|
||||
{
|
||||
var ids = files.Select(f => f.Id).ToArray();
|
||||
await db.Files.Where(o => ids.Contains(o.Id))
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
b => b.UsedCount,
|
||||
b => b.UsedCount + delta
|
||||
)
|
||||
);
|
||||
|
||||
await _PurgeCacheRangeAsync(files.Select(x => x.Id).ToList());
|
||||
}
|
||||
|
||||
|
||||
public async Task SetExpiresRangeAsync(ICollection<CloudFile> files, Duration? duration)
|
||||
{
|
||||
var ids = files.Select(f => f.Id).ToArray();
|
||||
await db.Files.Where(o => ids.Contains(o.Id))
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
b => b.ExpiredAt,
|
||||
duration.HasValue
|
||||
? b => SystemClock.Instance.GetCurrentInstant() + duration.Value
|
||||
: _ => null
|
||||
)
|
||||
);
|
||||
|
||||
await _PurgeCacheRangeAsync(files.Select(x => x.Id).ToList());
|
||||
}
|
||||
|
||||
public async Task SetUsageAsync(CloudFile file, string? usage)
|
||||
{
|
||||
await db.Files.Where(o => o.Id == file.Id)
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
b => b.Usage,
|
||||
_ => usage
|
||||
)
|
||||
);
|
||||
|
||||
await _PurgeCacheAsync(file.Id);
|
||||
}
|
||||
|
||||
public async Task SetUsageRangeAsync(ICollection<CloudFile> files, string? usage)
|
||||
{
|
||||
var ids = files.Select(f => f.Id).ToArray();
|
||||
await db.Files.Where(o => ids.Contains(o.Id))
|
||||
.ExecuteUpdateAsync(setter => setter.SetProperty(
|
||||
b => b.Usage,
|
||||
_ => usage
|
||||
)
|
||||
);
|
||||
|
||||
await _PurgeCacheRangeAsync(files.Select(x => x.Id).ToList());
|
||||
}
|
||||
|
||||
public async Task<(ICollection<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)>
|
||||
DiffAndSetUsageAsync(
|
||||
ICollection<string>? newFileIds,
|
||||
string? usage,
|
||||
ICollection<CloudFile>? previousFiles = null
|
||||
)
|
||||
{
|
||||
if (newFileIds == null) return ([], [], previousFiles ?? []);
|
||||
|
||||
var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync();
|
||||
var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary<string, CloudFile>();
|
||||
var current = records.ToDictionary(f => f.Id);
|
||||
|
||||
var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList();
|
||||
var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList();
|
||||
|
||||
if (added.Count > 0) await SetUsageRangeAsync(added, usage);
|
||||
if (removed.Count > 0) await SetUsageRangeAsync(removed, null);
|
||||
|
||||
return (newFileIds.Select(id => current[id]).ToList(), added, removed);
|
||||
}
|
||||
|
||||
public async Task<(ICollection<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)>
|
||||
DiffAndMarkFilesAsync(
|
||||
ICollection<string>? newFileIds,
|
||||
ICollection<CloudFile>? previousFiles = null
|
||||
)
|
||||
{
|
||||
if (newFileIds == null) return ([], [], previousFiles ?? []);
|
||||
|
||||
var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync();
|
||||
var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary<string, CloudFile>();
|
||||
var current = records.ToDictionary(f => f.Id);
|
||||
|
||||
var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList();
|
||||
var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList();
|
||||
|
||||
if (added.Count > 0) await MarkUsageRangeAsync(added, 1);
|
||||
if (removed.Count > 0) await MarkUsageRangeAsync(removed, -1);
|
||||
|
||||
return (newFileIds.Select(id => current[id]).ToList(), added, removed);
|
||||
}
|
||||
|
||||
public async Task<(ICollection<CloudFile> current, ICollection<CloudFile> added, ICollection<CloudFile> removed)>
|
||||
DiffAndSetExpiresAsync(
|
||||
ICollection<string>? newFileIds,
|
||||
Duration? duration,
|
||||
ICollection<CloudFile>? previousFiles = null
|
||||
)
|
||||
{
|
||||
if (newFileIds == null) return ([], [], previousFiles ?? []);
|
||||
|
||||
var records = await db.Files.Where(f => newFileIds.Contains(f.Id)).ToListAsync();
|
||||
var previous = previousFiles?.ToDictionary(f => f.Id) ?? new Dictionary<string, CloudFile>();
|
||||
var current = records.ToDictionary(f => f.Id);
|
||||
|
||||
var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList();
|
||||
var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList();
|
||||
|
||||
if (added.Count > 0) await SetExpiresRangeAsync(added, duration);
|
||||
if (removed.Count > 0) await SetExpiresRangeAsync(removed, null);
|
||||
|
||||
return (newFileIds.Select(id => current[id]).ToList(), added, removed);
|
||||
}
|
||||
|
||||
// Add this helper method to purge the cache for a specific file
|
||||
private async Task _PurgeCacheAsync(string fileId)
|
||||
|
||||
// Helper method to purge the cache for a specific file
|
||||
// Made internal to allow FileReferenceService to use it
|
||||
internal async Task _PurgeCacheAsync(string fileId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
// Add this helper method to purge cache for multiple files
|
||||
private async Task _PurgeCacheRangeAsync(ICollection<string> fileIds)
|
||||
// Helper method to purge cache for multiple files
|
||||
internal async Task _PurgeCacheRangeAsync(IEnumerable<string> fileIds)
|
||||
{
|
||||
var tasks = fileIds.Select(_PurgeCacheAsync);
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger<CloudFileUnusedRecyclingJob> logger)
|
||||
: IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
public async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references)
|
||||
{
|
||||
logger.LogInformation("Deleting unused cloud files...");
|
||||
var cachedFiles = new Dictionary<string, CloudFile>();
|
||||
var uncachedIds = new List<string>();
|
||||
|
||||
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Get files to delete along with their storage IDs
|
||||
var files = await db.Files
|
||||
.Where(f =>
|
||||
(f.ExpiredAt == null && f.UsedCount == 0 && f.CreatedAt < cutoff) ||
|
||||
(f.ExpiredAt != null && now >= f.ExpiredAt)
|
||||
)
|
||||
.ToListAsync();
|
||||
|
||||
if (files.Count == 0)
|
||||
// Check cache first
|
||||
foreach (var reference in references)
|
||||
{
|
||||
logger.LogInformation("No files to delete");
|
||||
return;
|
||||
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
|
||||
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
||||
|
||||
if (cachedFile != null)
|
||||
{
|
||||
cachedFiles[reference.Id] = cachedFile;
|
||||
}
|
||||
else
|
||||
{
|
||||
uncachedIds.Add(reference.Id);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation($"Found {files.Count} files to process...");
|
||||
// Load uncached files from database
|
||||
if (uncachedIds.Count > 0)
|
||||
{
|
||||
var dbFiles = await db.Files
|
||||
.Include(f => f.Account)
|
||||
.Where(f => uncachedIds.Contains(f.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// Group files by StorageId and find which ones are safe to delete
|
||||
var storageIds = files.Where(f => f.StorageId != null)
|
||||
.Select(f => f.StorageId!)
|
||||
.Distinct()
|
||||
// Add to cache
|
||||
foreach (var file in dbFiles)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
||||
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||
cachedFiles[file.Id] = file;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve original order
|
||||
return references
|
||||
.Select(r => cachedFiles.GetValueOrDefault(r.Id))
|
||||
.Where(f => f != null)
|
||||
.ToList();
|
||||
|
||||
var usedStorageIds = await db.Files
|
||||
.Where(f => f.StorageId != null &&
|
||||
storageIds.Contains(f.StorageId) &&
|
||||
!files.Select(ff => ff.Id).Contains(f.Id))
|
||||
.Select(f => f.StorageId!)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
// Group files for deletion
|
||||
var filesToDelete = files.Where(f => f.StorageId == null || !usedStorageIds.Contains(f.StorageId))
|
||||
.GroupBy(f => f.UploadedTo)
|
||||
.ToDictionary(grouping => grouping.Key!, grouping => grouping.ToList());
|
||||
|
||||
// Delete files by remote storage
|
||||
foreach (var group in filesToDelete.Where(group => !string.IsNullOrEmpty(group.Key)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dest = fs.GetRemoteStorageConfig(group.Key);
|
||||
var client = fs.CreateMinioClient(dest);
|
||||
if (client == null) continue;
|
||||
|
||||
// Create delete tasks for each file in the group
|
||||
var deleteTasks = group.Value.Select(file =>
|
||||
{
|
||||
var objectId = file.StorageId ?? file.Id;
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
client.RemoveObjectAsync(new RemoveObjectArgs()
|
||||
.WithBucket(dest.Bucket)
|
||||
.WithObject(objectId))
|
||||
};
|
||||
|
||||
if (file.HasCompression)
|
||||
{
|
||||
tasks.Add(client.RemoveObjectAsync(new RemoveObjectArgs()
|
||||
.WithBucket(dest.Bucket)
|
||||
.WithObject(objectId + ".compressed")));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
});
|
||||
|
||||
await Task.WhenAll(deleteTasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting files from remote storage {remote}", group.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all file records from the database
|
||||
var fileIds = files.Select(f => f.Id).ToList();
|
||||
await db.Files
|
||||
.Where(f => fileIds.Contains(f.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
logger.LogInformation($"Completed deleting {files.Count} files");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of references to a file based on CloudFileReference records
|
||||
/// </summary>
|
||||
/// <param name="fileId">The ID of the file</param>
|
||||
/// <returns>The number of references to the file</returns>
|
||||
public async Task<int> GetReferenceCountAsync(string fileId)
|
||||
{
|
||||
return await db.FileReferences
|
||||
.Where(r => r.FileId == fileId)
|
||||
.CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a file is referenced by any resource
|
||||
/// </summary>
|
||||
/// <param name="fileId">The ID of the file to check</param>
|
||||
/// <returns>True if the file is referenced, false otherwise</returns>
|
||||
public async Task<bool> IsReferencedAsync(string fileId)
|
||||
{
|
||||
return await db.FileReferences
|
||||
.Where(r => r.FileId == fileId)
|
||||
.AnyAsync();
|
||||
}
|
||||
}
|
||||
|
49
DysonNetwork.Sphere/Storage/ICloudFile.cs
Normal file
49
DysonNetwork.Sphere/Storage/ICloudFile.cs
Normal file
@ -0,0 +1,49 @@
|
||||
namespace DysonNetwork.Sphere.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Common interface for cloud file entities that can be used in file operations.
|
||||
/// This interface exposes the essential properties needed for file operations
|
||||
/// and is implemented by both CloudFile and CloudFileReferenceObject.
|
||||
/// </summary>
|
||||
public interface ICloudFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of the cloud file.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the cloud file.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file metadata dictionary.
|
||||
/// </summary>
|
||||
Dictionary<string, object>? FileMeta { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user metadata dictionary.
|
||||
/// </summary>
|
||||
Dictionary<string, object>? UserMeta { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MIME type of the file.
|
||||
/// </summary>
|
||||
string? MimeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash of the file content.
|
||||
/// </summary>
|
||||
string? Hash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of the file in bytes.
|
||||
/// </summary>
|
||||
long Size { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the file has a compressed version available.
|
||||
/// </summary>
|
||||
bool HasCompression { get; }
|
||||
}
|
Reference in New Issue
Block a user