408 lines
14 KiB
C#
408 lines
14 KiB
C#
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);
|
|
}
|
|
}
|