♻️ Extract the Storage service to DysonNetwork.Drive microservice
This commit is contained in:
13
DysonNetwork.Drive/Attributes/RequiredPermissionAttribute.cs
Normal file
13
DysonNetwork.Drive/Attributes/RequiredPermissionAttribute.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace DysonNetwork.Drive.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
||||
public class RequiredPermissionAttribute : AuthorizeAttribute
|
||||
{
|
||||
public RequiredPermissionAttribute(string permission) : base(permission)
|
||||
{
|
||||
Policy = permission;
|
||||
}
|
||||
}
|
68
DysonNetwork.Drive/Auth/AuthService.cs
Normal file
68
DysonNetwork.Drive/Auth/AuthService.cs
Normal file
@ -0,0 +1,68 @@
|
||||
using System.Security.Claims;
|
||||
using DysonNetwork.Drive.Data;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Drive.Auth;
|
||||
|
||||
public interface IAuthService
|
||||
{
|
||||
Task<string> GenerateJwtToken(Account account);
|
||||
Task<Account?> GetAuthenticatedAccountAsync(ClaimsPrincipal user);
|
||||
Task<Account?> GetAuthenticatedAccountAsync(HttpContext context);
|
||||
}
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly AppDatabase _db;
|
||||
|
||||
public AuthService(IConfiguration configuration, AppDatabase db)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public Task<string> GenerateJwtToken(Account account)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Secret"] ?? throw new InvalidOperationException("JWT Secret not configured"));
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, account.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, account.Username),
|
||||
new Claim(ClaimTypes.Email, account.Email)
|
||||
}),
|
||||
Expires = DateTime.UtcNow.AddDays(7),
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(key),
|
||||
SecurityAlgorithms.HmacSha256Signature)
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return Task.FromResult(tokenHandler.WriteToken(token));
|
||||
}
|
||||
|
||||
public async Task<Account?> GetAuthenticatedAccountAsync(ClaimsPrincipal user)
|
||||
{
|
||||
var userIdClaim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
return null;
|
||||
|
||||
return await _db.Set<Account>().FindAsync(userId);
|
||||
}
|
||||
|
||||
public async Task<Account?> GetAuthenticatedAccountAsync(HttpContext context)
|
||||
{
|
||||
return await GetAuthenticatedAccountAsync(context.User);
|
||||
}
|
||||
}
|
42
DysonNetwork.Drive/Auth/Session.cs
Normal file
42
DysonNetwork.Drive/Auth/Session.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Drive.Models;
|
||||
|
||||
namespace DysonNetwork.Drive.Auth;
|
||||
|
||||
public class Session
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
public Guid AccountId { get; set; }
|
||||
public virtual Account? Account { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string Token { get; set; } = null!;
|
||||
|
||||
public string? UserAgent { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset LastActiveAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Additional metadata
|
||||
public string? DeviceInfo { get; set; }
|
||||
public string? LocationInfo { get; set; }
|
||||
|
||||
public void UpdateLastActive()
|
||||
{
|
||||
LastActiveAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public bool IsExpired()
|
||||
{
|
||||
return ExpiresAt.HasValue && DateTimeOffset.UtcNow >= ExpiresAt.Value;
|
||||
}
|
||||
}
|
131
DysonNetwork.Drive/Clients/FileReferenceServiceClient.cs
Normal file
131
DysonNetwork.Drive/Clients/FileReferenceServiceClient.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Common.Interfaces;
|
||||
using DysonNetwork.Common.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
|
||||
namespace DysonNetwork.Drive.Clients
|
||||
{
|
||||
public class FileReferenceServiceClient : IFileReferenceServiceClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<FileReferenceServiceClient> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public FileReferenceServiceClient(HttpClient httpClient, ILogger<FileReferenceServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_jsonOptions = new JsonSerializerOptions()
|
||||
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
_jsonOptions.PropertyNameCaseInsensitive = true;
|
||||
}
|
||||
|
||||
public async Task<CloudFileReference> CreateReferenceAsync(
|
||||
string fileId,
|
||||
string usage,
|
||||
string resourceId,
|
||||
Instant? expiredAt = null,
|
||||
Duration? duration = null)
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
FileId = fileId,
|
||||
Usage = usage,
|
||||
ResourceId = resourceId,
|
||||
ExpiredAt = expiredAt,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(request, _jsonOptions),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync("api/filereferences", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonSerializer.DeserializeAsync<CloudFileReference>(responseStream, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize reference response");
|
||||
}
|
||||
|
||||
public async Task DeleteReferenceAsync(string referenceId)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"api/filereferences/{referenceId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task DeleteResourceReferencesAsync(string resourceId, string? usage = null)
|
||||
{
|
||||
var url = $"api/filereferences/resource/{Uri.EscapeDataString(resourceId)}";
|
||||
if (!string.IsNullOrEmpty(usage))
|
||||
{
|
||||
url += $"?usage={Uri.EscapeDataString(usage)}";
|
||||
}
|
||||
|
||||
var response = await _httpClient.DeleteAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<List<CloudFileReference>> GetFileReferencesAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(responseStream, _jsonOptions)
|
||||
?? new List<CloudFileReference>();
|
||||
}
|
||||
|
||||
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string? usage = null)
|
||||
{
|
||||
var url = $"api/filereferences/resource/{Uri.EscapeDataString(resourceId)}";
|
||||
if (!string.IsNullOrEmpty(usage))
|
||||
{
|
||||
url += $"?usage={Uri.EscapeDataString(usage)}";
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(responseStream, _jsonOptions)
|
||||
?? new List<CloudFileReference>();
|
||||
}
|
||||
|
||||
public async Task<bool> HasReferencesAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}/has-references");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<bool>(result, _jsonOptions);
|
||||
}
|
||||
|
||||
public async Task UpdateReferenceExpirationAsync(string referenceId, Instant? expiredAt)
|
||||
{
|
||||
var request = new { ExpiredAt = expiredAt };
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(request, _jsonOptions),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await _httpClient.PatchAsync($"api/filereferences/{referenceId}", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
99
DysonNetwork.Drive/Clients/FileServiceClient.cs
Normal file
99
DysonNetwork.Drive/Clients/FileServiceClient.cs
Normal file
@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Common.Interfaces;
|
||||
using DysonNetwork.Common.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Drive.Clients
|
||||
{
|
||||
public class FileServiceClient : IFileServiceClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<FileServiceClient> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
public FileServiceClient(HttpClient httpClient, ILogger<FileServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
}
|
||||
|
||||
public async Task<CloudFile> GetFileAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/files/{fileId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonSerializer.DeserializeAsync<CloudFile>(stream, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize file response");
|
||||
}
|
||||
|
||||
public async Task<Stream> GetFileStreamAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/files/{fileId}/download");
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStreamAsync();
|
||||
}
|
||||
|
||||
public async Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string? contentType = null)
|
||||
{
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StreamContent(fileStream), "file", fileName }
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsync("api/files/upload", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize upload response");
|
||||
}
|
||||
|
||||
public async Task DeleteFileAsync(string fileId)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"api/files/{fileId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<CloudFile> ProcessImageAsync(Stream imageStream, string fileName, string? contentType = null)
|
||||
{
|
||||
using var content = new MultipartFormDataContent
|
||||
{
|
||||
{ new StreamContent(imageStream), "image", fileName }
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsync("api/files/process-image", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions)
|
||||
?? throw new InvalidOperationException("Failed to deserialize image processing response");
|
||||
}
|
||||
|
||||
public async Task<string> GetFileUrl(string fileId, bool useCdn = false)
|
||||
{
|
||||
var url = $"api/files/{fileId}/url";
|
||||
if (useCdn)
|
||||
{
|
||||
url += "?useCdn=true";
|
||||
}
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<string>(result, _jsonOptions) ?? string.Empty;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
95
DysonNetwork.Drive/CloudFileUnusedRecyclingJob.cs
Normal file
95
DysonNetwork.Drive/CloudFileUnusedRecyclingJob.cs
Normal file
@ -0,0 +1,95 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using DysonNetwork.Sphere;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
public class CloudFileUnusedRecyclingJob(
|
||||
AppDatabase db,
|
||||
FileReferenceService fileRefService,
|
||||
ILogger<CloudFileUnusedRecyclingJob> logger
|
||||
)
|
||||
: IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Marking unused cloud files...");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
const int batchSize = 1000; // Process larger batches for efficiency
|
||||
var processedCount = 0;
|
||||
var markedCount = 0;
|
||||
var totalFiles = await db.Files.Where(f => !f.IsMarkedRecycle).CountAsync();
|
||||
|
||||
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles);
|
||||
|
||||
// Define a timestamp to limit the age of files we're processing in this run
|
||||
// This spreads the processing across multiple job runs for very large databases
|
||||
var ageThreshold = now - Duration.FromDays(30); // Process files up to 90 days old in this run
|
||||
|
||||
// Instead of loading all files at once, use pagination
|
||||
var hasMoreFiles = true;
|
||||
string? lastProcessedId = null;
|
||||
|
||||
while (hasMoreFiles)
|
||||
{
|
||||
// Query for the next batch of files using keyset pagination
|
||||
var filesQuery = db.Files
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
|
||||
|
||||
if (lastProcessedId != null)
|
||||
{
|
||||
filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
|
||||
}
|
||||
|
||||
var fileBatch = await filesQuery
|
||||
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination
|
||||
.Take(batchSize)
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (fileBatch.Count == 0)
|
||||
{
|
||||
hasMoreFiles = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
processedCount += fileBatch.Count;
|
||||
lastProcessedId = fileBatch.Last();
|
||||
|
||||
// Get all relevant file references for this batch
|
||||
var fileReferences = await fileRefService.GetReferencesAsync(fileBatch);
|
||||
|
||||
// Filter to find files that have no references or all expired references
|
||||
var filesToMark = fileBatch.Where(fileId =>
|
||||
!fileReferences.TryGetValue(fileId, out var references) ||
|
||||
references.Count == 0 ||
|
||||
references.All(r => r.ExpiredAt.HasValue && r.ExpiredAt.Value <= now)
|
||||
).ToList();
|
||||
|
||||
if (filesToMark.Count > 0)
|
||||
{
|
||||
// Use a bulk update for better performance - mark all qualifying files at once
|
||||
var updateCount = await db.Files
|
||||
.Where(f => filesToMark.Contains(f.Id))
|
||||
.ExecuteUpdateAsync(setter => setter
|
||||
.SetProperty(f => f.IsMarkedRecycle, true));
|
||||
|
||||
markedCount += updateCount;
|
||||
}
|
||||
|
||||
// Log progress periodically
|
||||
if (processedCount % 10000 == 0 || !hasMoreFiles)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling",
|
||||
processedCount, totalFiles, markedCount);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
|
||||
}
|
||||
}
|
222
DysonNetwork.Drive/Controllers/FileController.cs
Normal file
222
DysonNetwork.Drive/Controllers/FileController.cs
Normal file
@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Drive.Interfaces;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Drive.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/files")]
|
||||
[Authorize]
|
||||
public class FileController : ControllerBase
|
||||
{
|
||||
private readonly IFileService _fileService;
|
||||
private readonly ILogger<FileController> _logger;
|
||||
|
||||
public FileController(IFileService fileService, ILogger<FileController> logger)
|
||||
{
|
||||
_fileService = fileService ?? throw new ArgumentNullException(nameof(fileService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
[HttpGet("{fileId}")]
|
||||
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetFile(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await _fileService.GetFileAsync(fileId, cancellationToken);
|
||||
var stream = await _fileService.DownloadFileAsync(fileId, cancellationToken);
|
||||
|
||||
return File(stream, file.MimeType, file.OriginalName);
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
|
||||
return NotFound(new { message = $"File with ID {fileId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving file: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving the file." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
[ProducesResponseType(typeof(CloudFile), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { message = "No file uploaded." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
var uploadedFile = await _fileService.UploadFileAsync(
|
||||
stream,
|
||||
file.FileName,
|
||||
file.ContentType,
|
||||
null,
|
||||
cancellationToken);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetFile),
|
||||
new { fileId = uploadedFile.Id },
|
||||
uploadedFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error uploading file: {FileName}", file?.FileName);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while uploading the file." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{fileId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteFile(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _fileService.DeleteFileAsync(fileId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(new { message = $"File with ID {fileId} not found." });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting the file." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{fileId}/metadata")]
|
||||
[ProducesResponseType(typeof(CloudFile), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetFileMetadata(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await _fileService.GetFileAsync(fileId, cancellationToken);
|
||||
return Ok(file);
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
|
||||
return NotFound(new { message = $"File with ID {fileId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving file metadata: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving file metadata." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{fileId}/metadata")]
|
||||
[ProducesResponseType(typeof(CloudFile), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateFileMetadata(
|
||||
Guid fileId,
|
||||
[FromBody] Dictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (metadata == null || metadata.Count == 0)
|
||||
{
|
||||
return BadRequest(new { message = "No metadata provided." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updatedFile = await _fileService.UpdateFileMetadataAsync(fileId, metadata, cancellationToken);
|
||||
return Ok(updatedFile);
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
|
||||
return NotFound(new { message = $"File with ID {fileId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating file metadata: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while updating file metadata." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{fileId}/url")]
|
||||
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetFileUrl(Guid fileId, [FromQuery] int? expiresInSeconds = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
TimeSpan? expiry = expiresInSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(expiresInSeconds.Value)
|
||||
: null;
|
||||
|
||||
var url = await _fileService.GetFileUrlAsync(fileId, expiry, cancellationToken);
|
||||
return Ok(new { url });
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
|
||||
return NotFound(new { message = $"File with ID {fileId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating file URL: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while generating the file URL." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{fileId}/thumbnail")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetFileThumbnail(
|
||||
Guid fileId,
|
||||
[FromQuery] int? width = null,
|
||||
[FromQuery] int? height = null,
|
||||
[FromQuery] int? expiresInSeconds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
TimeSpan? expiry = expiresInSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(expiresInSeconds.Value)
|
||||
: null;
|
||||
|
||||
var url = await _fileService.GetFileThumbnailUrlAsync(
|
||||
fileId,
|
||||
width,
|
||||
height,
|
||||
expiry,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new { url });
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
|
||||
return NotFound(new { message = $"File with ID {fileId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating thumbnail URL: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while generating the thumbnail URL." });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
325
DysonNetwork.Drive/Controllers/FileReferenceController.cs
Normal file
325
DysonNetwork.Drive/Controllers/FileReferenceController.cs
Normal file
@ -0,0 +1,325 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Drive.Interfaces;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Drive.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/references")]
|
||||
[Authorize]
|
||||
public class FileReferenceController : ControllerBase
|
||||
{
|
||||
private readonly IFileReferenceService _referenceService;
|
||||
private readonly ILogger<FileReferenceController> _logger;
|
||||
|
||||
public FileReferenceController(
|
||||
IFileReferenceService referenceService,
|
||||
ILogger<FileReferenceController> logger)
|
||||
{
|
||||
_referenceService = referenceService ?? throw new ArgumentNullException(nameof(referenceService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateReference([FromBody] CreateReferenceRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reference = await _referenceService.CreateReferenceAsync(
|
||||
request.FileId,
|
||||
request.ResourceId,
|
||||
request.ResourceType,
|
||||
request.ReferenceType,
|
||||
request.ReferenceId,
|
||||
request.ReferenceName,
|
||||
request.ReferenceMimeType,
|
||||
request.ReferenceSize,
|
||||
request.ReferenceUrl,
|
||||
request.ReferenceThumbnailUrl,
|
||||
request.ReferencePreviewUrl,
|
||||
request.ReferenceMetadata,
|
||||
request.Metadata,
|
||||
cancellationToken);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetReference),
|
||||
new { referenceId = reference.Id },
|
||||
reference);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating file reference for file {FileId}", request.FileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while creating the file reference." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{referenceId}")]
|
||||
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetReference(Guid referenceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reference = await _referenceService.GetReferenceAsync(referenceId, cancellationToken);
|
||||
return Ok(reference);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reference not found: {ReferenceId}", referenceId);
|
||||
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving reference: {ReferenceId}", referenceId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving the reference." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("file/{fileId}")]
|
||||
[ProducesResponseType(typeof(IEnumerable<CloudFileReference>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetReferencesForFile(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var references = await _referenceService.GetReferencesForFileAsync(fileId, cancellationToken);
|
||||
return Ok(references);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving references for file: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving references for the file." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("resource/{resourceType}/{resourceId}")]
|
||||
[ProducesResponseType(typeof(IEnumerable<CloudFileReference>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetReferencesForResource(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var references = await _referenceService.GetReferencesForResourceAsync(resourceId, resourceType, cancellationToken);
|
||||
return Ok(references);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving references for resource: {ResourceType}/{ResourceId}", resourceType, resourceId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving references for the resource." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("type/{referenceType}")]
|
||||
[ProducesResponseType(typeof(IEnumerable<CloudFileReference>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetReferencesOfType(string referenceType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var references = await _referenceService.GetReferencesOfTypeAsync(referenceType, cancellationToken);
|
||||
return Ok(references);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving references of type: {ReferenceType}", referenceType);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving references of the specified type." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{referenceId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteReference(Guid referenceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await _referenceService.DeleteReferenceAsync(referenceId, cancellationToken);
|
||||
if (!deleted)
|
||||
{
|
||||
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting reference: {ReferenceId}", referenceId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting the reference." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("file/{fileId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> DeleteReferencesForFile(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _referenceService.DeleteReferencesForFileAsync(fileId, cancellationToken);
|
||||
return Ok(new { count });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting references for file: {FileId}", fileId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting references for the file." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("resource/{resourceType}/{resourceId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> DeleteReferencesForResource(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _referenceService.DeleteReferencesForResourceAsync(resourceId, resourceType, cancellationToken);
|
||||
return Ok(new { count });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting references for resource: {ResourceType}/{ResourceId}", resourceType, resourceId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting references for the resource." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{referenceId}/metadata")]
|
||||
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateReferenceMetadata(
|
||||
Guid referenceId,
|
||||
[FromBody] Dictionary<string, object> metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (metadata == null || metadata.Count == 0)
|
||||
{
|
||||
return BadRequest(new { message = "No metadata provided." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reference = await _referenceService.UpdateReferenceMetadataAsync(referenceId, metadata, cancellationToken);
|
||||
return Ok(reference);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reference not found: {ReferenceId}", referenceId);
|
||||
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating reference metadata: {ReferenceId}", referenceId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while updating the reference metadata." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{referenceId}/resource")]
|
||||
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateReferenceResource(
|
||||
Guid referenceId,
|
||||
[FromBody] UpdateReferenceResourceRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reference = await _referenceService.UpdateReferenceResourceAsync(
|
||||
referenceId,
|
||||
request.NewResourceId,
|
||||
request.NewResourceType,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(reference);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reference not found: {ReferenceId}", referenceId);
|
||||
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating reference resource: {ReferenceId}", referenceId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while updating the reference resource." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("exists/{fileId}/{resourceType}/{resourceId}")]
|
||||
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> HasReference(
|
||||
Guid fileId,
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
[FromQuery] string? referenceType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await _referenceService.HasReferenceAsync(
|
||||
fileId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
referenceType,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(new { exists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error checking reference existence - File: {FileId}, Resource: {ResourceType}/{ResourceId}, ReferenceType: {ReferenceType}",
|
||||
fileId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
referenceType);
|
||||
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while checking reference existence." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateReferenceRequest
|
||||
{
|
||||
public Guid FileId { get; set; }
|
||||
public string ResourceId { get; set; } = null!;
|
||||
public string ResourceType { get; set; } = null!;
|
||||
public string ReferenceType { get; set; } = null!;
|
||||
public string? ReferenceId { get; set; }
|
||||
public string? ReferenceName { get; set; }
|
||||
public string? ReferenceMimeType { get; set; }
|
||||
public long? ReferenceSize { get; set; }
|
||||
public string? ReferenceUrl { get; set; }
|
||||
public string? ReferenceThumbnailUrl { get; set; }
|
||||
public string? ReferencePreviewUrl { get; set; }
|
||||
public string? ReferenceMetadata { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateReferenceResourceRequest
|
||||
{
|
||||
public string NewResourceId { get; set; } = null!;
|
||||
public string NewResourceType { get; set; } = null!;
|
||||
}
|
||||
}
|
127
DysonNetwork.Drive/Data/AppDatabase.cs
Normal file
127
DysonNetwork.Drive/Data/AppDatabase.cs
Normal file
@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using DysonNetwork.Drive.Extensions;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
|
||||
|
||||
namespace DysonNetwork.Drive.Data;
|
||||
|
||||
public class AppDatabase : DbContext, IDisposable
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AppDatabase(DbContextOptions<AppDatabase> options, IConfiguration configuration)
|
||||
: base(options)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public DbSet<CloudFile> Files { get; set; } = null!;
|
||||
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
if (!optionsBuilder.IsConfigured)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
_configuration.GetConnectionString("DefaultConnection"),
|
||||
o => o.UseNodaTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Apply snake_case naming convention for all entities
|
||||
foreach (var entity in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
// Replace table names
|
||||
entity.SetTableName(entity.GetTableName()?.ToSnakeCase());
|
||||
|
||||
// Replace column names
|
||||
foreach (var property in entity.GetProperties())
|
||||
{
|
||||
property.SetColumnName(property.Name.ToSnakeCase());
|
||||
}
|
||||
|
||||
// Replace keys
|
||||
foreach (var key in entity.GetKeys())
|
||||
{
|
||||
key.SetName(key.GetName()?.ToSnakeCase());
|
||||
}
|
||||
|
||||
// Replace foreign keys
|
||||
foreach (var key in entity.GetForeignKeys())
|
||||
{
|
||||
key.SetConstraintName(key.GetConstraintName()?.ToSnakeCase());
|
||||
}
|
||||
|
||||
// Replace indexes
|
||||
foreach (var index in entity.GetIndexes())
|
||||
{
|
||||
index.SetDatabaseName(index.GetDatabaseName()?.ToSnakeCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Configure CloudFile entity
|
||||
modelBuilder.Entity<CloudFile>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.StoragePath).IsUnique();
|
||||
entity.HasIndex(e => e.ContentHash);
|
||||
entity.HasIndex(e => e.UploadedById);
|
||||
entity.HasIndex(e => e.CreatedAt);
|
||||
|
||||
entity.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.Name).IsRequired();
|
||||
entity.Property(e => e.OriginalName).IsRequired();
|
||||
entity.Property(e => e.MimeType).IsRequired();
|
||||
entity.Property(e => e.StoragePath).IsRequired();
|
||||
|
||||
// Configure JSONB column for ExtendedMetadata
|
||||
entity.Property(e => e.ExtendedMetadata)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
// Configure relationships
|
||||
entity.HasMany(e => e.References)
|
||||
.WithOne(e => e.File)
|
||||
.HasForeignKey(e => e.FileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// Configure CloudFileReference entity
|
||||
modelBuilder.Entity<CloudFileReference>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => new { e.ResourceId, e.ResourceType, e.ReferenceType });
|
||||
entity.HasIndex(e => e.ReferenceId);
|
||||
|
||||
entity.Property(e => e.Id).ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.ResourceId).IsRequired();
|
||||
entity.Property(e => e.ResourceType).IsRequired();
|
||||
entity.Property(e => e.ReferenceType).IsRequired();
|
||||
|
||||
// Configure JSONB column for Metadata
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
// Configure relationship with CloudFile
|
||||
entity.HasOne(e => e.File)
|
||||
.WithMany(e => e.References)
|
||||
.HasForeignKey(e => e.FileId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
base.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
31
DysonNetwork.Drive/DysonNetwork.Drive.csproj
Normal file
31
DysonNetwork.Drive/DysonNetwork.Drive.csproj
Normal file
@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
|
||||
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
||||
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
|
||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.6" />
|
||||
<PackageReference Include="Minio" Version="6.0.5" />
|
||||
<PackageReference Include="NetVips" Version="3.1.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
|
||||
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
43
DysonNetwork.Drive/Extensions/StringExtensions.cs
Normal file
43
DysonNetwork.Drive/Extensions/StringExtensions.cs
Normal file
@ -0,0 +1,43 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DysonNetwork.Drive.Extensions;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
private static readonly Regex _matchFirstCap = new(@"(.)([A-Z][a-z])");
|
||||
private static readonly Regex _matchAllCap = new(@"([a-z0-9])([A-Z])");
|
||||
|
||||
public static string ToSnakeCase(this string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Handle the first character
|
||||
var result = new StringBuilder();
|
||||
result.Append(char.ToLowerInvariant(input[0]));
|
||||
|
||||
// Process the rest of the string
|
||||
for (int i = 1; i < input.Length; i++)
|
||||
{
|
||||
if (char.IsUpper(input[i]))
|
||||
{
|
||||
result.Append('_');
|
||||
result.Append(char.ToLowerInvariant(input[i]));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Append(input[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace any remaining uppercase letters with lowercase
|
||||
var output = result.ToString().ToLowerInvariant();
|
||||
|
||||
// Handle special cases (acronyms)
|
||||
output = _matchFirstCap.Replace(output, "$1_$2");
|
||||
output = _matchAllCap.Replace(output, "$1_$2");
|
||||
|
||||
return output.ToLowerInvariant();
|
||||
}
|
||||
}
|
123
DysonNetwork.Drive/FileController.cs
Normal file
123
DysonNetwork.Drive/FileController.cs
Normal file
@ -0,0 +1,123 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Minio.DataModel.Args;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
// Using fully qualified names to avoid ambiguity with DysonNetwork.Common.Models
|
||||
using DysonNetwork.Drive.Attributes;
|
||||
using DysonNetwork.Drive.Models;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
[ApiController]
|
||||
[Route("/files")]
|
||||
public class FileController(
|
||||
AppDatabase db,
|
||||
FileService fs,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment env,
|
||||
FileReferenceMigrationService rms
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult> OpenFile(string id, [FromQuery] bool original = false)
|
||||
{
|
||||
var file = await fs.GetFileAsync(id);
|
||||
if (file is null) return NotFound();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
||||
|
||||
if (file.UploadedTo is null)
|
||||
{
|
||||
var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!;
|
||||
var filePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
|
||||
if (!System.IO.File.Exists(filePath)) return new NotFoundResult();
|
||||
return PhysicalFile(filePath, file.MimeType ?? "application/octet-stream", file.Name);
|
||||
}
|
||||
|
||||
var dest = fs.GetRemoteStorageConfig(file.UploadedTo);
|
||||
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
|
||||
|
||||
if (!original && file.HasCompression)
|
||||
fileName += ".compressed";
|
||||
|
||||
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
|
||||
{
|
||||
var proxyUrl = dest.ImageProxy;
|
||||
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
|
||||
var fullUri = new Uri(baseUri, fileName);
|
||||
return Redirect(fullUri.ToString());
|
||||
}
|
||||
|
||||
if (dest.AccessProxy is not null)
|
||||
{
|
||||
var proxyUrl = dest.AccessProxy;
|
||||
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
|
||||
var fullUri = new Uri(baseUri, fileName);
|
||||
return Redirect(fullUri.ToString());
|
||||
}
|
||||
|
||||
if (dest.EnableSigned)
|
||||
{
|
||||
var client = fs.CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
return BadRequest(
|
||||
"Failed to configure client for remote destination, file got an invalid storage remote.");
|
||||
|
||||
var bucket = dest.Bucket;
|
||||
var openUrl = await client.PresignedGetObjectAsync(
|
||||
new PresignedGetObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(fileName)
|
||||
.WithExpiry(3600)
|
||||
);
|
||||
|
||||
return Redirect(openUrl);
|
||||
}
|
||||
|
||||
// Fallback redirect to the S3 endpoint (public read)
|
||||
var protocol = dest.EnableSsl ? "https" : "http";
|
||||
// Use the path bucket lookup mode
|
||||
return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{fileName}");
|
||||
}
|
||||
|
||||
[HttpGet("{id}/info")]
|
||||
public async Task<ActionResult<Models.CloudFile>> GetFileInfo(string id)
|
||||
{
|
||||
var file = await db.Files.FindAsync(id);
|
||||
if (file is null) return NotFound();
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> DeleteFile(string id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Models.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var file = await db.Files
|
||||
.Where(e => e.Id == id)
|
||||
.Where(e => e.Account.Id == userId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (file is null) return NotFound();
|
||||
|
||||
await fs.DeleteFileAsync(file);
|
||||
|
||||
db.Files.Remove(file);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("/maintenance/migrateReferences")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance.files.references")]
|
||||
public async Task<ActionResult> MigrateFileReferences()
|
||||
{
|
||||
await rms.ScanAndMigrateReferences();
|
||||
return Ok();
|
||||
}
|
||||
}
|
68
DysonNetwork.Drive/FileExpirationJob.cs
Normal file
68
DysonNetwork.Drive/FileExpirationJob.cs
Normal file
@ -0,0 +1,68 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using DysonNetwork.Sphere;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
436
DysonNetwork.Drive/FileReferenceService.cs
Normal file
436
DysonNetwork.Drive/FileReferenceService.cs
Normal file
@ -0,0 +1,436 @@
|
||||
using DysonNetwork.Common.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using DysonNetwork.Sphere;
|
||||
using DysonNetwork.Common.Models;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
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
|
||||
var 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();
|
||||
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 references for a specific resource and usage
|
||||
/// </summary>
|
||||
/// <param name="resourceId">The ID of the resource</param>
|
||||
/// <param name="usage">The usage context</param>
|
||||
/// <returns>The number of deleted references</returns>
|
||||
public async Task<int> DeleteResourceReferencesAsync(string resourceId, string usage)
|
||||
{
|
||||
var references = await db.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||
.ToListAsync();
|
||||
|
||||
if (!references.Any())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
292
DysonNetwork.Drive/FileService.ReferenceMigration.cs
Normal file
292
DysonNetwork.Drive/FileService.ReferenceMigration.cs
Normal file
@ -0,0 +1,292 @@
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Sphere;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
public class FileReferenceMigrationService(AppDatabase db)
|
||||
{
|
||||
public async Task ScanAndMigrateReferences()
|
||||
{
|
||||
// Scan Posts for file references
|
||||
await ScanPosts();
|
||||
|
||||
// Scan Messages for file references
|
||||
await ScanMessages();
|
||||
|
||||
// Scan Profiles for file references
|
||||
await ScanProfiles();
|
||||
|
||||
// Scan Chat entities for file references
|
||||
await ScanChatRooms();
|
||||
|
||||
// Scan Realms for file references
|
||||
await ScanRealms();
|
||||
|
||||
// Scan Publishers for file references
|
||||
await ScanPublishers();
|
||||
|
||||
// Scan Stickers for file references
|
||||
await ScanStickers();
|
||||
}
|
||||
|
||||
private async Task ScanPosts()
|
||||
{
|
||||
var posts = await db.Posts
|
||||
.Include(p => p.OutdatedAttachments)
|
||||
.Where(p => p.OutdatedAttachments.Any())
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var post in posts)
|
||||
{
|
||||
var updatedAttachments = new List<CloudFileReferenceObject>();
|
||||
|
||||
foreach (var attachment in post.OutdatedAttachments)
|
||||
{
|
||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == attachment.Id);
|
||||
if (file != null)
|
||||
{
|
||||
// Create a reference for the file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = file.Id,
|
||||
File = file,
|
||||
Usage = "post",
|
||||
ResourceId = post.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
updatedAttachments.Add(file.ToReferenceObject());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep the existing reference object if file not found
|
||||
updatedAttachments.Add(attachment.ToReferenceObject());
|
||||
}
|
||||
}
|
||||
|
||||
post.Attachments = updatedAttachments;
|
||||
db.Posts.Update(post);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task ScanMessages()
|
||||
{
|
||||
var messages = await db.ChatMessages
|
||||
.Include(m => m.OutdatedAttachments)
|
||||
.Where(m => m.OutdatedAttachments.Any())
|
||||
.ToListAsync();
|
||||
|
||||
var fileReferences = messages.SelectMany(message => message.OutdatedAttachments.Select(attachment =>
|
||||
new CloudFileReference
|
||||
{
|
||||
FileId = attachment.Id,
|
||||
File = attachment,
|
||||
Usage = "chat",
|
||||
ResourceId = message.ResourceIdentifier,
|
||||
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
})
|
||||
).ToList();
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
message.Attachments = message.OutdatedAttachments.Select(a => a.ToReferenceObject()).ToList();
|
||||
db.ChatMessages.Update(message);
|
||||
}
|
||||
|
||||
await db.BulkInsertAsync(fileReferences);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task ScanChatRooms()
|
||||
{
|
||||
var chatRooms = await db.ChatRooms
|
||||
.Where(c => c.PictureId != null || c.BackgroundId != null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var chatRoom in chatRooms)
|
||||
{
|
||||
if (chatRoom is { PictureId: not null, Picture: null })
|
||||
{
|
||||
var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == chatRoom.PictureId);
|
||||
if (avatarFile != null)
|
||||
{
|
||||
// Create a reference for the avatar file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = avatarFile.Id,
|
||||
File = avatarFile,
|
||||
Usage = "chatroom.picture",
|
||||
ResourceId = chatRoom.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
chatRoom.Picture = avatarFile.ToReferenceObject();
|
||||
db.ChatRooms.Update(chatRoom);
|
||||
}
|
||||
}
|
||||
|
||||
if (chatRoom is not { BackgroundId: not null, Background: null }) continue;
|
||||
var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == chatRoom.BackgroundId);
|
||||
if (bannerFile == null) continue;
|
||||
{
|
||||
// Create a reference for the banner file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = bannerFile.Id,
|
||||
File = bannerFile,
|
||||
Usage = "chatroom.background",
|
||||
ResourceId = chatRoom.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
chatRoom.Background = bannerFile.ToReferenceObject();
|
||||
db.ChatRooms.Update(chatRoom);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task ScanRealms()
|
||||
{
|
||||
var realms = await db.Realms
|
||||
.Where(r => r.PictureId != null && r.BackgroundId != null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var realm in realms)
|
||||
{
|
||||
// Process avatar if it exists
|
||||
if (realm is { PictureId: not null, Picture: null })
|
||||
{
|
||||
var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == realm.PictureId);
|
||||
if (avatarFile != null)
|
||||
{
|
||||
// Create a reference for the avatar file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = avatarFile.Id,
|
||||
File = avatarFile,
|
||||
Usage = "realm.picture",
|
||||
ResourceId = realm.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
realm.Picture = avatarFile.ToReferenceObject();
|
||||
}
|
||||
}
|
||||
|
||||
// Process banner if it exists
|
||||
if (realm is { BackgroundId: not null, Background: null })
|
||||
{
|
||||
var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == realm.BackgroundId);
|
||||
if (bannerFile != null)
|
||||
{
|
||||
// Create a reference for the banner file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = bannerFile.Id,
|
||||
File = bannerFile,
|
||||
Usage = "realm.background",
|
||||
ResourceId = realm.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
realm.Background = bannerFile.ToReferenceObject();
|
||||
}
|
||||
}
|
||||
|
||||
db.Realms.Update(realm);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task ScanPublishers()
|
||||
{
|
||||
var publishers = await db.Publishers
|
||||
.Where(p => p.PictureId != null || p.BackgroundId != null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var publisher in publishers)
|
||||
{
|
||||
if (publisher is { PictureId: not null, Picture: null })
|
||||
{
|
||||
var pictureFile = await db.Files.FirstOrDefaultAsync(f => f.Id == publisher.PictureId);
|
||||
if (pictureFile != null)
|
||||
{
|
||||
// Create a reference for the picture file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = pictureFile.Id,
|
||||
File = pictureFile,
|
||||
Usage = "publisher.picture",
|
||||
ResourceId = publisher.Id.ToString()
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
publisher.Picture = pictureFile.ToReferenceObject();
|
||||
}
|
||||
}
|
||||
|
||||
if (publisher is { BackgroundId: not null, Background: null })
|
||||
{
|
||||
var backgroundFile = await db.Files.FirstOrDefaultAsync(f => f.Id == publisher.BackgroundId);
|
||||
if (backgroundFile != null)
|
||||
{
|
||||
// Create a reference for the background file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = backgroundFile.Id,
|
||||
File = backgroundFile,
|
||||
Usage = "publisher.background",
|
||||
ResourceId = publisher.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
publisher.Background = backgroundFile.ToReferenceObject();
|
||||
}
|
||||
}
|
||||
|
||||
db.Publishers.Update(publisher);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task ScanStickers()
|
||||
{
|
||||
var stickers = await db.Stickers
|
||||
.Where(s => s.ImageId != null && s.Image == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var sticker in stickers)
|
||||
{
|
||||
var imageFile = await db.Files.FirstOrDefaultAsync(f => f.Id == sticker.ImageId);
|
||||
if (imageFile != null)
|
||||
{
|
||||
// Create a reference for the sticker image file
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
FileId = imageFile.Id,
|
||||
File = imageFile,
|
||||
Usage = "sticker.image",
|
||||
ResourceId = sticker.ResourceIdentifier
|
||||
};
|
||||
|
||||
await db.FileReferences.AddAsync(reference);
|
||||
sticker.Image = imageFile.ToReferenceObject();
|
||||
db.Stickers.Update(sticker);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
561
DysonNetwork.Drive/FileService.cs
Normal file
561
DysonNetwork.Drive/FileService.cs
Normal file
@ -0,0 +1,561 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using DysonNetwork.Common.Models;
|
||||
using DysonNetwork.Common.Services;
|
||||
using DysonNetwork.Sphere;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
using NodaTime;
|
||||
using tusdotnet.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using AngleSharp.Text;
|
||||
using FFMpegCore;
|
||||
using NetVips;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
public class FileService(
|
||||
AppDatabase db,
|
||||
IConfiguration configuration,
|
||||
TusDiskStore store,
|
||||
ILogger<FileService> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICacheService cache
|
||||
)
|
||||
{
|
||||
private const string CacheKeyPrefix = "file:";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>
|
||||
/// The api for getting file meta with cache,
|
||||
/// the best use case is for accessing the file data.
|
||||
///
|
||||
/// <b>This function won't load uploader's information, only keep minimal file meta</b>
|
||||
/// </summary>
|
||||
/// <param name="fileId">The id of the cloud file requested</param>
|
||||
/// <returns>The minimal file meta</returns>
|
||||
public async Task<CloudFile?> GetFileAsync(string fileId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||
|
||||
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
||||
if (cachedFile is not null)
|
||||
return cachedFile;
|
||||
|
||||
var file = await db.Files
|
||||
.Include(f => f.Account)
|
||||
.Where(f => f.Id == fileId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (file != null)
|
||||
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private static readonly string TempFilePrefix = "dyn-cloudfile";
|
||||
|
||||
private static readonly string[] AnimatedImageTypes =
|
||||
["image/gif", "image/apng", "image/webp", "image/avif"];
|
||||
|
||||
// The analysis file method no longer will remove the GPS EXIF data
|
||||
// It should be handled on the client side, and for some specific cases it should be keep
|
||||
public async Task<CloudFile> ProcessNewFileAsync(
|
||||
Account.Account account,
|
||||
string fileId,
|
||||
Stream stream,
|
||||
string fileName,
|
||||
string? contentType
|
||||
)
|
||||
{
|
||||
var result = new List<(string filePath, string suffix)>();
|
||||
|
||||
var ogFilePath = Path.GetFullPath(Path.Join(configuration.GetValue<string>("Tus:StorePath"), fileId));
|
||||
var fileSize = stream.Length;
|
||||
var hash = await HashFileAsync(stream, fileSize: fileSize);
|
||||
contentType ??= !fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName);
|
||||
|
||||
var file = new CloudFile
|
||||
{
|
||||
Id = fileId,
|
||||
Name = fileName,
|
||||
MimeType = contentType,
|
||||
Size = fileSize,
|
||||
Hash = hash,
|
||||
AccountId = accountId
|
||||
};
|
||||
|
||||
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == hash);
|
||||
file.StorageId = existingFile is not null ? existingFile.StorageId : file.Id;
|
||||
|
||||
if (existingFile is not null)
|
||||
{
|
||||
file.FileMeta = existingFile.FileMeta;
|
||||
file.HasCompression = existingFile.HasCompression;
|
||||
file.SensitiveMarks = existingFile.SensitiveMarks;
|
||||
|
||||
db.Files.Add(file);
|
||||
await db.SaveChangesAsync();
|
||||
return file;
|
||||
}
|
||||
|
||||
switch (contentType.Split('/')[0])
|
||||
{
|
||||
case "image":
|
||||
var blurhash =
|
||||
BlurHashSharp.SkiaSharp.BlurHashEncoder.Encode(xComponent: 3, yComponent: 3, filename: ogFilePath);
|
||||
|
||||
// Rewind stream
|
||||
stream.Position = 0;
|
||||
|
||||
// Use NetVips for the rest
|
||||
using (var vipsImage = NetVips.Image.NewFromStream(stream))
|
||||
{
|
||||
var width = vipsImage.Width;
|
||||
var height = vipsImage.Height;
|
||||
var format = vipsImage.Get("vips-loader") ?? "unknown";
|
||||
|
||||
// Try to get orientation from exif data
|
||||
var orientation = 1;
|
||||
var meta = new Dictionary<string, object>
|
||||
{
|
||||
["blur"] = blurhash,
|
||||
["format"] = format,
|
||||
["width"] = width,
|
||||
["height"] = height,
|
||||
["orientation"] = orientation,
|
||||
};
|
||||
Dictionary<string, object> exif = [];
|
||||
|
||||
foreach (var field in vipsImage.GetFields())
|
||||
{
|
||||
var value = vipsImage.Get(field);
|
||||
|
||||
// Skip GPS-related EXIF fields to remove location data
|
||||
if (IsIgnoredField(field))
|
||||
continue;
|
||||
|
||||
if (field.StartsWith("exif-")) exif[field.Replace("exif-", "")] = value;
|
||||
else meta[field] = value;
|
||||
|
||||
if (field == "orientation") orientation = (int)value;
|
||||
}
|
||||
|
||||
if (orientation is 6 or 8)
|
||||
(width, height) = (height, width);
|
||||
|
||||
var aspectRatio = height != 0 ? (double)width / height : 0;
|
||||
|
||||
meta["exif"] = exif;
|
||||
meta["ratio"] = aspectRatio;
|
||||
file.FileMeta = meta;
|
||||
}
|
||||
|
||||
break;
|
||||
case "video":
|
||||
case "audio":
|
||||
try
|
||||
{
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(ogFilePath);
|
||||
file.FileMeta = new Dictionary<string, object>
|
||||
{
|
||||
["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 ?? [],
|
||||
["chapters"] = mediaInfo.Chapters,
|
||||
};
|
||||
if (mediaInfo.PrimaryVideoStream is not null)
|
||||
file.FileMeta["ratio"] = mediaInfo.PrimaryVideoStream.Width / mediaInfo.PrimaryVideoStream.Height;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError("File analyzed failed, unable collect video / audio information: {Message}",
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
db.Files.Add(file);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var nfs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Processed file {fileId}, now trying optimizing if possible...", fileId);
|
||||
|
||||
if (contentType.Split('/')[0] == "image")
|
||||
{
|
||||
// Skip compression for animated image types
|
||||
var animatedMimeTypes = AnimatedImageTypes;
|
||||
if (Enumerable.Contains(animatedMimeTypes, contentType))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId,
|
||||
contentType
|
||||
);
|
||||
var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
||||
result.Add((tempFilePath, string.Empty));
|
||||
return;
|
||||
}
|
||||
|
||||
file.MimeType = "image/webp";
|
||||
|
||||
using var vipsImage = Image.NewFromFile(ogFilePath);
|
||||
var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
||||
vipsImage.Autorot().WriteToFile(imagePath + ".webp",
|
||||
new VOption { { "lossless", true }, { "strip", true } });
|
||||
result.Add((imagePath + ".webp", string.Empty));
|
||||
|
||||
if (vipsImage.Width * vipsImage.Height >= 1024 * 1024)
|
||||
{
|
||||
var scale = 1024.0 / Math.Max(vipsImage.Width, vipsImage.Height);
|
||||
var imageCompressedPath =
|
||||
Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}-compressed");
|
||||
|
||||
// Create and save image within the same synchronous block to avoid disposal issues
|
||||
using var compressedImage = vipsImage.Resize(scale);
|
||||
compressedImage.Autorot().WriteToFile(imageCompressedPath + ".webp",
|
||||
new VOption { { "Q", 80 }, { "strip", true } });
|
||||
|
||||
result.Add((imageCompressedPath + ".webp", ".compressed"));
|
||||
file.HasCompression = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No extra process for video, add it to the upload queue.
|
||||
result.Add((ogFilePath, string.Empty));
|
||||
}
|
||||
|
||||
logger.LogInformation("Optimized file {fileId}, now uploading...", fileId);
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
List<Task<CloudFile>> tasks = [];
|
||||
tasks.AddRange(result.Select(item =>
|
||||
nfs.UploadFileToRemoteAsync(file, item.filePath, null, item.suffix, true))
|
||||
);
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
file = await tasks.First();
|
||||
}
|
||||
else
|
||||
{
|
||||
file = await nfs.UploadFileToRemoteAsync(file, stream, null);
|
||||
}
|
||||
|
||||
logger.LogInformation("Uploaded file {fileId} done!", fileId);
|
||||
|
||||
var scopedDb = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
await scopedDb.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(setter => setter
|
||||
.SetProperty(f => f.UploadedAt, file.UploadedAt)
|
||||
.SetProperty(f => f.UploadedTo, file.UploadedTo)
|
||||
.SetProperty(f => f.MimeType, file.MimeType)
|
||||
.SetProperty(f => f.HasCompression, file.HasCompression)
|
||||
);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
logger.LogError(err, "Failed to process {fileId}", fileId);
|
||||
}
|
||||
|
||||
await stream.DisposeAsync();
|
||||
await store.DeleteFileAsync(file.Id, CancellationToken.None);
|
||||
await nfs._PurgeCacheAsync(file.Id);
|
||||
});
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private static async Task<string> HashFileAsync(Stream stream, int chunkSize = 1024 * 1024, long? fileSize = null)
|
||||
{
|
||||
fileSize ??= stream.Length;
|
||||
if (fileSize > chunkSize * 1024 * 5)
|
||||
return await HashFastApproximateAsync(stream, chunkSize);
|
||||
|
||||
using var md5 = MD5.Create();
|
||||
var hashBytes = await md5.ComputeHashAsync(stream);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static async Task<string> HashFastApproximateAsync(Stream stream, int chunkSize = 1024 * 1024)
|
||||
{
|
||||
// Scale the chunk size to kB level
|
||||
chunkSize *= 1024;
|
||||
|
||||
using var md5 = MD5.Create();
|
||||
|
||||
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.ComputeHash(buffer, 0, bytesRead);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, string filePath, string? targetRemote,
|
||||
string? suffix = null, bool selfDestruct = false)
|
||||
{
|
||||
var fileStream = File.OpenRead(filePath);
|
||||
var result = await UploadFileToRemoteAsync(file, fileStream, targetRemote, suffix);
|
||||
if (selfDestruct) File.Delete(filePath);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> UploadFileToRemoteAsync(CloudFile file, Stream stream, string? targetRemote,
|
||||
string? suffix = null)
|
||||
{
|
||||
if (file.UploadedAt.HasValue) return file;
|
||||
|
||||
file.UploadedTo = targetRemote ?? configuration.GetValue<string>("Storage:PreferredRemote")!;
|
||||
|
||||
var dest = GetRemoteStorageConfig(file.UploadedTo);
|
||||
var client = CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to configure client for remote destination '{file.UploadedTo}'"
|
||||
);
|
||||
|
||||
var bucket = dest.Bucket;
|
||||
var contentType = file.MimeType ?? "application/octet-stream";
|
||||
|
||||
await client.PutObjectAsync(new PutObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(string.IsNullOrWhiteSpace(suffix) ? file.Id : file.Id + suffix)
|
||||
.WithStreamData(stream) // Fix this disposed
|
||||
.WithObjectSize(stream.Length)
|
||||
.WithContentType(contentType)
|
||||
);
|
||||
|
||||
file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task DeleteFileAsync(CloudFile file)
|
||||
{
|
||||
await DeleteFileDataAsync(file);
|
||||
|
||||
db.Remove(file);
|
||||
await db.SaveChangesAsync();
|
||||
await _PurgeCacheAsync(file.Id);
|
||||
}
|
||||
|
||||
public async Task DeleteFileDataAsync(CloudFile file)
|
||||
{
|
||||
if (file.StorageId is null) return;
|
||||
if (file.UploadedTo is null) 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);
|
||||
if (client is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to configure client for remote destination '{file.UploadedTo}'"
|
||||
);
|
||||
|
||||
var bucket = dest.Bucket;
|
||||
var objectId = file.StorageId ?? file.Id; // Use StorageId if available, otherwise fall back to Id
|
||||
|
||||
await client.RemoveObjectAsync(
|
||||
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId)
|
||||
);
|
||||
|
||||
if (file.HasCompression)
|
||||
{
|
||||
// Also remove the compressed version if it exists
|
||||
try
|
||||
{
|
||||
await client.RemoveObjectAsync(
|
||||
new RemoveObjectArgs().WithBucket(bucket).WithObject(objectId + ".compressed")
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when deleting compressed version
|
||||
logger.LogWarning("Failed to delete compressed version of file {fileId}", file.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public RemoteStorageConfig GetRemoteStorageConfig(string destination)
|
||||
{
|
||||
var destinations = configuration.GetSection("Storage:Remote").Get<List<RemoteStorageConfig>>()!;
|
||||
var dest = destinations.FirstOrDefault(d => d.Id == destination);
|
||||
if (dest is null) throw new InvalidOperationException($"Remote destination '{destination}' not found");
|
||||
return dest;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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 async Task<List<CloudFile?>> LoadFromReference(List<CloudFileReferenceObject> references)
|
||||
{
|
||||
var cachedFiles = new Dictionary<string, CloudFile>();
|
||||
var uncachedIds = new List<string>();
|
||||
|
||||
// Check cache first
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{reference.Id}";
|
||||
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
||||
|
||||
if (cachedFile != null)
|
||||
{
|
||||
cachedFiles[reference.Id] = cachedFile;
|
||||
}
|
||||
else
|
||||
{
|
||||
uncachedIds.Add(reference.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an EXIF field contains GPS location data
|
||||
/// </summary>
|
||||
/// <param name="fieldName">The EXIF field name</param>
|
||||
/// <returns>True if the field contains GPS data, false otherwise</returns>
|
||||
private static bool IsGpsExifField(string fieldName)
|
||||
{
|
||||
// Common GPS EXIF field names
|
||||
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"
|
||||
};
|
||||
|
||||
return gpsFields.Any(gpsField =>
|
||||
fieldName.Equals(gpsField, StringComparison.OrdinalIgnoreCase) ||
|
||||
fieldName.StartsWith("gps", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsIgnoredField(string fieldName)
|
||||
{
|
||||
if (IsGpsExifField(fieldName)) return true;
|
||||
if (fieldName.EndsWith("-data")) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
66
DysonNetwork.Drive/FlushBufferService.cs
Normal file
66
DysonNetwork.Drive/FlushBufferService.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
public interface IFlushHandler<T>
|
||||
{
|
||||
Task FlushAsync(IReadOnlyList<T> items);
|
||||
}
|
||||
|
||||
public class FlushBufferService
|
||||
{
|
||||
private readonly Dictionary<Type, object> _buffers = new();
|
||||
private readonly Lock _lockObject = new();
|
||||
|
||||
private ConcurrentQueue<T> _GetOrCreateBuffer<T>()
|
||||
{
|
||||
var type = typeof(T);
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (!_buffers.TryGetValue(type, out var buffer))
|
||||
{
|
||||
buffer = new ConcurrentQueue<T>();
|
||||
_buffers[type] = buffer;
|
||||
}
|
||||
return (ConcurrentQueue<T>)buffer;
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue<T>(T item)
|
||||
{
|
||||
var buffer = _GetOrCreateBuffer<T>();
|
||||
buffer.Enqueue(item);
|
||||
}
|
||||
|
||||
public async Task FlushAsync<T>(IFlushHandler<T> handler)
|
||||
{
|
||||
var buffer = _GetOrCreateBuffer<T>();
|
||||
var workingQueue = new List<T>();
|
||||
|
||||
while (buffer.TryDequeue(out var item))
|
||||
{
|
||||
workingQueue.Add(item);
|
||||
}
|
||||
|
||||
if (workingQueue.Count == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await handler.FlushAsync(workingQueue);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// If flush fails, re-queue the items
|
||||
foreach (var item in workingQueue)
|
||||
buffer.Enqueue(item);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public int GetPendingCount<T>()
|
||||
{
|
||||
var buffer = _GetOrCreateBuffer<T>();
|
||||
return buffer.Count;
|
||||
}
|
||||
}
|
27
DysonNetwork.Drive/Handlers/ActionLogFlushHandler.cs
Normal file
27
DysonNetwork.Drive/Handlers/ActionLogFlushHandler.cs
Normal file
@ -0,0 +1,27 @@
|
||||
|
||||
using EFCore.BulkExtensions;
|
||||
using Quartz;
|
||||
using DysonNetwork.Sphere;
|
||||
using DysonNetwork.Common.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DysonNetwork.Drive.Handlers;
|
||||
|
||||
public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<ActionLog>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<ActionLog> items)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
await db.BulkInsertAsync(items, config => config.ConflictOption = ConflictOption.Ignore);
|
||||
}
|
||||
}
|
||||
|
||||
public class ActionLogFlushJob(FlushBufferService fbs, ActionLogFlushHandler hdl) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await fbs.FlushAsync(hdl);
|
||||
}
|
||||
}
|
63
DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs
Normal file
63
DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs
Normal file
@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using DysonNetwork.Drive.Auth;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DysonNetwork.Drive.Handlers;
|
||||
|
||||
public class LastActiveInfo
|
||||
{
|
||||
public Session Session { get; set; } = null!;
|
||||
public Account Account { get; set; } = null!;
|
||||
public Instant SeenAt { get; set; }
|
||||
}
|
||||
|
||||
public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<LastActiveInfo>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
// Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt
|
||||
var distinctItems = items
|
||||
.GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id))
|
||||
.Select(g => g.OrderByDescending(x => x.SeenAt).First())
|
||||
.ToList();
|
||||
|
||||
// Build dictionaries so we can match session/account IDs to their new "last seen" timestamps
|
||||
var sessionIdMap = distinctItems
|
||||
.GroupBy(x => x.SessionId)
|
||||
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
|
||||
|
||||
var accountIdMap = distinctItems
|
||||
.GroupBy(x => x.AccountId)
|
||||
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
|
||||
|
||||
// Update sessions using native EF Core ExecuteUpdateAsync
|
||||
foreach (var kvp in sessionIdMap)
|
||||
{
|
||||
await db.AuthSessions
|
||||
.Where(s => s.Id == kvp.Key)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, kvp.Value));
|
||||
}
|
||||
|
||||
// Update account profiles using native EF Core ExecuteUpdateAsync
|
||||
foreach (var kvp in accountIdMap)
|
||||
{
|
||||
await db.AccountProfiles
|
||||
.Where(a => a.AccountId == kvp.Key)
|
||||
.ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, kvp.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await fbs.FlushAsync(hdl);
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using DysonNetwork.Sphere;
|
||||
using DysonNetwork.Common.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DysonNetwork.Drive.Handlers;
|
||||
|
||||
public class MessageReadReceiptFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<MessageReadReceipt>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<MessageReadReceipt> items)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var distinctId = items
|
||||
.DistinctBy(x => x.SenderId)
|
||||
.Select(x => x.SenderId)
|
||||
.ToList();
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
await db.ChatMembers.Where(r => distinctId.Contains(r.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(m => m.LastReadAt, now)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public class ReadReceiptFlushJob(FlushBufferService fbs, MessageReadReceiptFlushHandler hdl) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await fbs.FlushAsync(hdl);
|
||||
}
|
||||
}
|
55
DysonNetwork.Drive/Handlers/PostViewFlushHandler.cs
Normal file
55
DysonNetwork.Drive/Handlers/PostViewFlushHandler.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using DysonNetwork.Drive.Services;
|
||||
|
||||
namespace DysonNetwork.Drive.Handlers;
|
||||
|
||||
public class PostViewFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<PostViewInfo>
|
||||
{
|
||||
public async Task FlushAsync(IReadOnlyList<PostViewInfo> items)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var cache = scope.ServiceProvider.GetRequiredService<ICacheService>();
|
||||
|
||||
// Group views by post
|
||||
var postViews = items
|
||||
.GroupBy(x => x.PostId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Calculate total views and unique views per post
|
||||
foreach (var postId in postViews.Keys)
|
||||
{
|
||||
// Calculate unique views by distinct viewer IDs (not null)
|
||||
var uniqueViews = postViews[postId]
|
||||
.Where(v => !string.IsNullOrEmpty(v.ViewerId))
|
||||
.Select(v => v.ViewerId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
// Total views is just the count of all items for this post
|
||||
var totalViews = postViews[postId].Count;
|
||||
|
||||
// Update the post in the database
|
||||
await db.Posts
|
||||
.Where(p => p.Id == postId)
|
||||
.ExecuteUpdateAsync(p => p
|
||||
.SetProperty(x => x.ViewsTotal, x => x.ViewsTotal + totalViews)
|
||||
.SetProperty(x => x.ViewsUnique, x => x.ViewsUnique + uniqueViews));
|
||||
|
||||
// Invalidate any cache entries for this post
|
||||
await cache.RemoveAsync($"post:{postId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PostViewFlushJob(FlushBufferService fbs, PostViewFlushHandler hdl) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
await fbs.FlushAsync(hdl);
|
||||
}
|
||||
}
|
40
DysonNetwork.Drive/Interfaces/IFileReferenceService.cs
Normal file
40
DysonNetwork.Drive/Interfaces/IFileReferenceService.cs
Normal file
@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Drive.Models;
|
||||
|
||||
namespace DysonNetwork.Drive.Interfaces;
|
||||
|
||||
public interface IFileReferenceService
|
||||
{
|
||||
Task<CloudFileReference> CreateReferenceAsync(
|
||||
Guid fileId,
|
||||
string resourceId,
|
||||
string resourceType,
|
||||
string referenceType,
|
||||
string? referenceId = null,
|
||||
string? referenceName = null,
|
||||
string? referenceMimeType = null,
|
||||
long? referenceSize = null,
|
||||
string? referenceUrl = null,
|
||||
string? referenceThumbnailUrl = null,
|
||||
string? referencePreviewUrl = null,
|
||||
string? referenceMetadata = null,
|
||||
IDictionary<string, object>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CloudFileReference> GetReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<CloudFileReference>> GetReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<CloudFileReference>> GetReferencesForResourceAsync(string resourceId, string resourceType, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<CloudFileReference>> GetReferencesOfTypeAsync(string referenceType, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default);
|
||||
Task<int> DeleteReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<int> DeleteReferencesForResourceAsync(string resourceId, string resourceType, CancellationToken cancellationToken = default);
|
||||
Task<CloudFileReference> UpdateReferenceMetadataAsync(Guid referenceId, IDictionary<string, object> metadata, CancellationToken cancellationToken = default);
|
||||
Task<bool> ReferenceExistsAsync(Guid referenceId, CancellationToken cancellationToken = default);
|
||||
Task<bool> HasReferenceAsync(Guid fileId, string resourceId, string resourceType, string? referenceType = null, CancellationToken cancellationToken = default);
|
||||
Task<CloudFileReference> UpdateReferenceResourceAsync(Guid referenceId, string newResourceId, string newResourceType, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<CloudFile>> GetFilesForResourceAsync(string resourceId, string resourceType, string? referenceType = null, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<CloudFile>> GetFilesForReferenceTypeAsync(string referenceType, CancellationToken cancellationToken = default);
|
||||
}
|
27
DysonNetwork.Drive/Interfaces/IFileService.cs
Normal file
27
DysonNetwork.Drive/Interfaces/IFileService.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Drive.Models;
|
||||
|
||||
namespace DysonNetwork.Drive.Interfaces;
|
||||
|
||||
public interface IFileService
|
||||
{
|
||||
Task<CloudFile> GetFileAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<Stream> DownloadFileAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string contentType, IDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteFileAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<CloudFile> UpdateFileMetadataAsync(Guid fileId, IDictionary<string, string> metadata, CancellationToken cancellationToken = default);
|
||||
Task<bool> FileExistsAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<string> GetFileUrlAsync(Guid fileId, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
|
||||
Task<string> GetFileThumbnailUrlAsync(Guid fileId, int? width = null, int? height = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
|
||||
Task<CloudFile> CopyFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default);
|
||||
Task<CloudFile> MoveFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default);
|
||||
Task<CloudFile> RenameFileAsync(Guid fileId, string newName, CancellationToken cancellationToken = default);
|
||||
Task<long> GetFileSizeAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<string> GetFileHashAsync(Guid fileId, CancellationToken cancellationToken = default);
|
||||
Task<Stream> GetFileThumbnailAsync(Guid fileId, int? width = null, int? height = null, CancellationToken cancellationToken = default);
|
||||
Task<CloudFile> SetFileVisibilityAsync(Guid fileId, bool isPublic, CancellationToken cancellationToken = default);
|
||||
}
|
39
DysonNetwork.Drive/Models/Account.cs
Normal file
39
DysonNetwork.Drive/Models/Account.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace DysonNetwork.Drive.Models;
|
||||
|
||||
public class Account : ModelBase
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Username { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string Email { get; set; } = null!;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public bool IsVerified { get; set; } = false;
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<CloudFile> Files { get; set; } = new List<CloudFile>();
|
||||
|
||||
// Timestamps
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
|
||||
// Methods
|
||||
public bool HasPermission(Permission permission)
|
||||
{
|
||||
// TODO: Implement actual permission checking logic
|
||||
return true;
|
||||
}
|
||||
}
|
67
DysonNetwork.Drive/Models/CloudFile.cs
Normal file
67
DysonNetwork.Drive/Models/CloudFile.cs
Normal file
@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Models;
|
||||
|
||||
public class CloudFile : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string OriginalName { get; set; } = null!;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string MimeType { get; set; } = null!;
|
||||
|
||||
public long Size { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string StoragePath { get; set; } = null!;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string StorageProvider { get; set; } = "local";
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? ThumbnailPath { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? PreviewPath { get; set; }
|
||||
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public float? Duration { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? Metadata { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public JsonDocument? ExtendedMetadata { get; set; }
|
||||
|
||||
public bool IsPublic { get; set; }
|
||||
public bool IsTemporary { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public Instant? ExpiresAt { get; set; }
|
||||
public new Instant? DeletedAt { get; set; }
|
||||
|
||||
public Guid? UploadedById { get; set; }
|
||||
public string? UploadedByType { get; set; }
|
||||
|
||||
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ExtendedMetadata?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
61
DysonNetwork.Drive/Models/CloudFileReference.cs
Normal file
61
DysonNetwork.Drive/Models/CloudFileReference.cs
Normal file
@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Models;
|
||||
|
||||
public class CloudFileReference : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[MaxLength(2048)]
|
||||
public string ResourceId { get; set; } = null!;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ResourceType { get; set; } = null!;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ReferenceType { get; set; } = null!;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? ReferenceId { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? ReferenceName { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? ReferenceMimeType { get; set; }
|
||||
|
||||
public long? ReferenceSize { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? ReferenceUrl { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? ReferenceThumbnailUrl { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? ReferencePreviewUrl { get; set; }
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? ReferenceMetadata { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public JsonDocument? Metadata { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public Instant? ExpiresAt { get; set; }
|
||||
|
||||
public Guid FileId { get; set; }
|
||||
public virtual CloudFile File { get; set; } = null!;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Metadata?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
10
DysonNetwork.Drive/Models/ModelBase.cs
Normal file
10
DysonNetwork.Drive/Models/ModelBase.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace DysonNetwork.Drive.Models;
|
||||
|
||||
public abstract class ModelBase
|
||||
{
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
}
|
19
DysonNetwork.Drive/Models/Permission.cs
Normal file
19
DysonNetwork.Drive/Models/Permission.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace DysonNetwork.Drive.Models;
|
||||
|
||||
public enum Permission
|
||||
{
|
||||
// File permissions
|
||||
File_Read,
|
||||
File_Write,
|
||||
File_Delete,
|
||||
File_Share,
|
||||
|
||||
// Admin permissions
|
||||
Admin_Access,
|
||||
Admin_ManageUsers,
|
||||
Admin_ManageFiles,
|
||||
|
||||
// Special permissions
|
||||
BypassRateLimit,
|
||||
BypassQuota
|
||||
}
|
50
DysonNetwork.Drive/Models/Post.cs
Normal file
50
DysonNetwork.Drive/Models/Post.cs
Normal file
@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DysonNetwork.Drive.Models;
|
||||
|
||||
public class Post : ModelBase
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[Required]
|
||||
[MaxLength(1024)]
|
||||
public string Title { get; set; } = null!;
|
||||
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public Guid AuthorId { get; set; }
|
||||
public virtual Account? Author { get; set; }
|
||||
|
||||
public bool IsPublished { get; set; } = false;
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
|
||||
// Navigation properties
|
||||
public virtual ICollection<PostViewInfo> Views { get; set; } = new List<PostViewInfo>();
|
||||
public virtual ICollection<CloudFileReference> Attachments { get; set; } = new List<CloudFileReference>();
|
||||
}
|
||||
|
||||
public class PostViewInfo
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid PostId { get; set; }
|
||||
public virtual Post? Post { get; set; }
|
||||
|
||||
public string? UserAgent { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? Referrer { get; set; }
|
||||
|
||||
public string? ViewerId { get; set; }
|
||||
|
||||
public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
// Additional metadata
|
||||
public string? CountryCode { get; set; }
|
||||
public string? DeviceType { get; set; }
|
||||
public string? Platform { get; set; }
|
||||
public string? Browser { get; set; }
|
||||
}
|
193
DysonNetwork.Drive/Program.cs
Normal file
193
DysonNetwork.Drive/Program.cs
Normal file
@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Drive.Data;
|
||||
using DysonNetwork.Drive.Extensions;
|
||||
using DysonNetwork.Drive.Interfaces;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using DysonNetwork.Drive.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add configuration
|
||||
var configuration = builder.Configuration;
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
// Add NodaTime
|
||||
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
|
||||
// Add database context
|
||||
builder.Services.AddDbContext<AppDatabase>((serviceProvider, options) =>
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||
if (string.IsNullOrEmpty(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Database connection string 'DefaultConnection' not found.");
|
||||
}
|
||||
|
||||
options.UseNpgsql(
|
||||
connectionString,
|
||||
npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.UseNodaTime();
|
||||
npgsqlOptions.MigrationsAssembly(typeof(Program).Assembly.FullName);
|
||||
});
|
||||
|
||||
options.UseSnakeCaseNamingConvention();
|
||||
});
|
||||
|
||||
// Register services
|
||||
builder.Services.AddScoped<IFileService, FileService>();
|
||||
builder.Services.AddScoped<IFileReferenceService, FileReferenceService>();
|
||||
|
||||
// Configure CORS
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
// Configure JWT Authentication
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = configuration["Jwt:Authority"];
|
||||
options.Audience = configuration["Jwt:Audience"];
|
||||
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
|
||||
});
|
||||
|
||||
// Configure Swagger
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "DysonNetwork.Drive API",
|
||||
Version = "v1",
|
||||
Description = "API for managing files and file references in the Dyson Network",
|
||||
Contact = new OpenApiContact
|
||||
{
|
||||
Name = "Dyson Network Team",
|
||||
Email = "support@dyson.network"
|
||||
}
|
||||
});
|
||||
|
||||
// Include XML comments for API documentation
|
||||
var xmlFile = $"{typeof(Program).Assembly.GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
if (File.Exists(xmlPath))
|
||||
{
|
||||
c.IncludeXmlComments(xmlPath);
|
||||
}
|
||||
|
||||
// Configure JWT Bearer Authentication for Swagger
|
||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
|
||||
Name = "Authorization",
|
||||
In = ParameterLocation.Header,
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
|
||||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
},
|
||||
Scheme = "oauth2",
|
||||
Name = "Bearer",
|
||||
In = ParameterLocation.Header,
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Configure HTTP client for external services
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDatabase>();
|
||||
|
||||
// Add logging
|
||||
builder.Services.AddLogging(configure => configure.AddConsole().AddDebug());
|
||||
|
||||
// Build the application
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "DysonNetwork.Drive API v1");
|
||||
c.RoutePrefix = "swagger";
|
||||
});
|
||||
}
|
||||
|
||||
// Apply database migrations
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
try
|
||||
{
|
||||
var dbContext = services.GetRequiredService<AppDatabase>();
|
||||
if (dbContext.Database.IsNpgsql())
|
||||
{
|
||||
await dbContext.Database.MigrateAsync();
|
||||
app.Logger.LogInformation("Database migrations applied successfully.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "An error occurred while applying database migrations.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.Logger.LogInformation("Starting DysonNetwork.Drive application...");
|
||||
|
||||
await app.RunAsync();
|
13
DysonNetwork.Drive/Properties/launchSettings.json
Normal file
13
DysonNetwork.Drive/Properties/launchSettings.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"profiles": {
|
||||
"DysonNetwork.Drive": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5073",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
321
DysonNetwork.Drive/Services/FileReferenceService.cs
Normal file
321
DysonNetwork.Drive/Services/FileReferenceService.cs
Normal file
@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Drive.Data;
|
||||
using DysonNetwork.Drive.Interfaces;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Services;
|
||||
|
||||
public class FileReferenceService : IFileReferenceService, IDisposable
|
||||
{
|
||||
private readonly AppDatabase _dbContext;
|
||||
private readonly IFileService _fileService;
|
||||
private readonly IClock _clock;
|
||||
private readonly ILogger<FileReferenceService> _logger;
|
||||
private bool _disposed = false;
|
||||
|
||||
public FileReferenceService(
|
||||
AppDatabase dbContext,
|
||||
IFileService fileService,
|
||||
IClock clock,
|
||||
ILogger<FileReferenceService> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_fileService = fileService ?? throw new ArgumentNullException(nameof(fileService));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CloudFileReference> CreateReferenceAsync(
|
||||
Guid fileId,
|
||||
string resourceId,
|
||||
string resourceType,
|
||||
string referenceType,
|
||||
string? referenceId = null,
|
||||
string? referenceName = null,
|
||||
string? referenceMimeType = null,
|
||||
long? referenceSize = null,
|
||||
string? referenceUrl = null,
|
||||
string? referenceThumbnailUrl = null,
|
||||
string? referencePreviewUrl = null,
|
||||
string? referenceMetadata = null,
|
||||
IDictionary<string, object>? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Verify file exists
|
||||
var fileExists = await _fileService.FileExistsAsync(fileId, cancellationToken);
|
||||
if (!fileExists)
|
||||
{
|
||||
throw new FileNotFoundException($"File with ID {fileId} not found.");
|
||||
}
|
||||
|
||||
var reference = new CloudFileReference
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FileId = fileId,
|
||||
ResourceId = resourceId,
|
||||
ResourceType = resourceType,
|
||||
ReferenceType = referenceType,
|
||||
ReferenceId = referenceId,
|
||||
ReferenceName = referenceName,
|
||||
ReferenceMimeType = referenceMimeType,
|
||||
ReferenceSize = referenceSize,
|
||||
ReferenceUrl = referenceUrl,
|
||||
ReferenceThumbnailUrl = referenceThumbnailUrl,
|
||||
ReferencePreviewUrl = referencePreviewUrl,
|
||||
ReferenceMetadata = referenceMetadata,
|
||||
IsActive = true,
|
||||
CreatedAt = _clock.GetCurrentInstant().ToDateTimeOffset()
|
||||
};
|
||||
|
||||
if (metadata != null && metadata.Any())
|
||||
{
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
reference.Metadata = JsonDocument.Parse(JsonSerializer.Serialize(metadata, options));
|
||||
}
|
||||
|
||||
_dbContext.FileReferences.Add(reference);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created reference {ReferenceId} for file {FileId} to resource {ResourceType}/{ResourceId}",
|
||||
reference.Id, fileId, resourceType, resourceId);
|
||||
|
||||
return reference;
|
||||
}
|
||||
|
||||
public async Task<CloudFileReference> GetReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = await _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Include(r => r.File)
|
||||
.FirstOrDefaultAsync(r => r.Id == referenceId, cancellationToken);
|
||||
|
||||
if (reference == null)
|
||||
{
|
||||
throw new KeyNotFoundException($"Reference with ID {referenceId} not found.");
|
||||
}
|
||||
|
||||
return reference;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CloudFileReference>> GetReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.FileId == fileId && r.IsActive)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CloudFileReference>> GetReferencesForResourceAsync(
|
||||
string resourceId,
|
||||
string resourceType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.ResourceId == resourceId &&
|
||||
r.ResourceType == resourceType &&
|
||||
r.IsActive)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CloudFileReference>> GetReferencesOfTypeAsync(
|
||||
string referenceType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.ReferenceType == referenceType && r.IsActive)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = await _dbContext.FileReferences
|
||||
.FirstOrDefaultAsync(r => r.Id == referenceId, cancellationToken);
|
||||
|
||||
if (reference == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
reference.IsActive = false;
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Deleted reference {ReferenceId}", referenceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var references = await _dbContext.FileReferences
|
||||
.Where(r => r.FileId == fileId && r.IsActive)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var reference in references)
|
||||
{
|
||||
reference.IsActive = false;
|
||||
}
|
||||
|
||||
var count = await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Deleted {Count} references for file {FileId}", count, fileId);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteReferencesForResourceAsync(
|
||||
string resourceId,
|
||||
string resourceType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var references = await _dbContext.FileReferences
|
||||
.Where(r => r.ResourceId == resourceId &&
|
||||
r.ResourceType == resourceType &&
|
||||
r.IsActive)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var reference in references)
|
||||
{
|
||||
reference.IsActive = false;
|
||||
}
|
||||
|
||||
var count = await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deleted {Count} references for resource {ResourceType}/{ResourceId}",
|
||||
count, resourceType, resourceId);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<CloudFileReference> UpdateReferenceMetadataAsync(
|
||||
Guid referenceId,
|
||||
IDictionary<string, object> metadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = await GetReferenceAsync(referenceId, cancellationToken);
|
||||
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
reference.Metadata = JsonDocument.Parse(JsonSerializer.Serialize(metadata, options));
|
||||
reference.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Updated metadata for reference {ReferenceId}", referenceId);
|
||||
|
||||
return reference;
|
||||
}
|
||||
|
||||
public async Task<bool> ReferenceExistsAsync(Guid referenceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.AnyAsync(r => r.Id == referenceId && r.IsActive, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> HasReferenceAsync(
|
||||
Guid fileId,
|
||||
string resourceId,
|
||||
string resourceType,
|
||||
string? referenceType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Where(r => r.FileId == fileId &&
|
||||
r.ResourceId == resourceId &&
|
||||
r.ResourceType == resourceType &&
|
||||
r.IsActive);
|
||||
|
||||
if (!string.IsNullOrEmpty(referenceType))
|
||||
{
|
||||
query = query.Where(r => r.ReferenceType == referenceType);
|
||||
}
|
||||
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CloudFileReference> UpdateReferenceResourceAsync(
|
||||
Guid referenceId,
|
||||
string newResourceId,
|
||||
string newResourceType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = await GetReferenceAsync(referenceId, cancellationToken);
|
||||
|
||||
reference.ResourceId = newResourceId;
|
||||
reference.ResourceType = newResourceType;
|
||||
reference.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated reference {ReferenceId} to point to resource {ResourceType}/{ResourceId}",
|
||||
referenceId, newResourceType, newResourceId);
|
||||
|
||||
return reference;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CloudFile>> GetFilesForResourceAsync(
|
||||
string resourceId,
|
||||
string resourceType,
|
||||
string? referenceType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Include(r => r.File)
|
||||
.Where(r => r.ResourceId == resourceId &&
|
||||
r.ResourceType == resourceType &&
|
||||
r.IsActive);
|
||||
|
||||
if (!string.IsNullOrEmpty(referenceType))
|
||||
{
|
||||
query = query.Where(r => r.ReferenceType == referenceType);
|
||||
}
|
||||
|
||||
var references = await query.ToListAsync(cancellationToken);
|
||||
return references.Select(r => r.File!);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CloudFile>> GetFilesForReferenceTypeAsync(
|
||||
string referenceType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var references = await _dbContext.FileReferences
|
||||
.AsNoTracking()
|
||||
.Include(r => r.File)
|
||||
.Where(r => r.ReferenceType == referenceType && r.IsActive)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return references.Select(r => r.File!);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_dbContext?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
301
DysonNetwork.Drive/Services/FileService.cs
Normal file
301
DysonNetwork.Drive/Services/FileService.cs
Normal file
@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Drive.Data;
|
||||
using DysonNetwork.Drive.Extensions;
|
||||
using DysonNetwork.Drive.Interfaces;
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Drive.Services;
|
||||
|
||||
public class FileService : IFileService, IDisposable
|
||||
{
|
||||
private readonly ILogger<FileService> _logger;
|
||||
private readonly AppDatabase _dbContext;
|
||||
private readonly IClock _clock;
|
||||
private bool _disposed = false;
|
||||
|
||||
public FileService(AppDatabase dbContext, IClock clock, ILogger<FileService> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CloudFile> GetFileAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await _dbContext.Files
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(f => f.Id == fileId, cancellationToken);
|
||||
|
||||
if (file == null)
|
||||
{
|
||||
throw new FileNotFoundException($"File with ID {fileId} not found.");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadFileAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await GetFileAsync(fileId, cancellationToken);
|
||||
|
||||
// In a real implementation, this would stream the file from storage (e.g., S3, local filesystem)
|
||||
// For now, we'll return a MemoryStream with a placeholder
|
||||
var placeholder = $"This is a placeholder for file {fileId} with name {file.Name}";
|
||||
var memoryStream = new MemoryStream();
|
||||
var writer = new StreamWriter(memoryStream);
|
||||
await writer.WriteAsync(placeholder);
|
||||
await writer.FlushAsync();
|
||||
memoryStream.Position = 0;
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> UploadFileAsync(
|
||||
Stream fileStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
IDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (fileStream == null) throw new ArgumentNullException(nameof(fileStream));
|
||||
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentNullException(nameof(fileName));
|
||||
if (string.IsNullOrWhiteSpace(contentType)) throw new ArgumentNullException(nameof(contentType));
|
||||
|
||||
// In a real implementation, this would upload to a storage service
|
||||
var now = _clock.GetCurrentInstant();
|
||||
var file = new CloudFile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = Path.GetFileName(fileName),
|
||||
OriginalName = fileName,
|
||||
MimeType = contentType,
|
||||
Size = fileStream.Length,
|
||||
StoragePath = $"uploads/{now.ToUnixTimeMilliseconds()}/{Guid.NewGuid()}/{Path.GetFileName(fileName)}",
|
||||
StorageProvider = "local", // or "s3", "azure", etc.
|
||||
CreatedAt = now.ToDateTimeOffset(),
|
||||
IsPublic = false,
|
||||
IsTemporary = false,
|
||||
IsDeleted = false
|
||||
};
|
||||
|
||||
if (metadata != null)
|
||||
{
|
||||
file.Metadata = System.Text.Json.JsonSerializer.Serialize(metadata);
|
||||
}
|
||||
|
||||
_dbContext.Files.Add(file);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Uploaded file {FileId} with name {FileName}", file.Id, file.Name);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteFileAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await _dbContext.Files.FindAsync(new object[] { fileId }, cancellationToken);
|
||||
if (file == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// In a real implementation, this would also delete the file from storage
|
||||
file.IsDeleted = true;
|
||||
file.DeletedAt = _clock.GetCurrentInstant();
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Soft-deleted file {FileId}", fileId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> UpdateFileMetadataAsync(Guid fileId, IDictionary<string, string> metadata, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await GetFileAsync(fileId, cancellationToken);
|
||||
|
||||
file.Metadata = System.Text.Json.JsonSerializer.Serialize(metadata);
|
||||
var now = _clock.GetCurrentInstant();
|
||||
file.UpdatedAt = new DateTimeOffset(now.ToDateTimeUtc(), TimeSpan.Zero);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Updated metadata for file {FileId}", fileId);
|
||||
return file;
|
||||
}
|
||||
|
||||
public Task<bool> FileExistsAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _dbContext.Files
|
||||
.AsNoTracking()
|
||||
.AnyAsync(f => f.Id == fileId && !f.IsDeleted, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string> GetFileUrlAsync(Guid fileId, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would generate a signed URL with the specified expiry
|
||||
return Task.FromResult($"https://storage.dyson.network/files/{fileId}");
|
||||
}
|
||||
|
||||
public Task<string> GetFileThumbnailUrlAsync(Guid fileId, int? width = null, int? height = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would generate a signed thumbnail URL
|
||||
var size = width.HasValue || height.HasValue
|
||||
? $"_{width ?? 0}x{height ?? 0}"
|
||||
: string.Empty;
|
||||
|
||||
return Task.FromResult($"https://storage.dyson.network/thumbnails/{fileId}{size}");
|
||||
}
|
||||
|
||||
public async Task<CloudFile> CopyFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourceFile = await GetFileAsync(sourceFileId, cancellationToken);
|
||||
|
||||
var newFile = new CloudFile
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = newName ?? sourceFile.Name,
|
||||
OriginalName = sourceFile.OriginalName,
|
||||
MimeType = sourceFile.MimeType,
|
||||
Size = sourceFile.Size,
|
||||
StoragePath = $"copies/{_clock.GetCurrentInstant().ToUnixTimeMilliseconds()}/{Guid.NewGuid()}/{sourceFile.Name}",
|
||||
StorageProvider = sourceFile.StorageProvider,
|
||||
ContentHash = sourceFile.ContentHash,
|
||||
ThumbnailPath = sourceFile.ThumbnailPath,
|
||||
PreviewPath = sourceFile.PreviewPath,
|
||||
Width = sourceFile.Width,
|
||||
Height = sourceFile.Height,
|
||||
Duration = sourceFile.Duration,
|
||||
Metadata = newMetadata != null
|
||||
? System.Text.Json.JsonSerializer.Serialize(newMetadata)
|
||||
: sourceFile.Metadata,
|
||||
IsPublic = sourceFile.IsPublic,
|
||||
IsTemporary = sourceFile.IsTemporary,
|
||||
IsDeleted = false,
|
||||
ExpiresAt = sourceFile.ExpiresAt,
|
||||
UploadedById = sourceFile.UploadedById,
|
||||
UploadedByType = sourceFile.UploadedByType,
|
||||
CreatedAt = _clock.GetCurrentInstant().ToDateTimeOffset(),
|
||||
UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset()
|
||||
};
|
||||
|
||||
_dbContext.Files.Add(newFile);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Copied file {SourceFileId} to {NewFileId}", sourceFileId, newFile.Id);
|
||||
|
||||
return newFile;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> MoveFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourceFile = await GetFileAsync(sourceFileId, cancellationToken);
|
||||
|
||||
// In a real implementation, this would move the file in storage
|
||||
var newPath = $"moved/{_clock.GetCurrentInstant().ToUnixTimeMilliseconds()}/{Guid.NewGuid()}/{newName ?? sourceFile.Name}";
|
||||
|
||||
sourceFile.Name = newName ?? sourceFile.Name;
|
||||
sourceFile.StoragePath = newPath;
|
||||
sourceFile.UpdatedAt = _clock.GetCurrentInstant();
|
||||
|
||||
if (newMetadata != null)
|
||||
{
|
||||
sourceFile.Metadata = System.Text.Json.JsonSerializer.Serialize(newMetadata);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Moved file {FileId} to {NewPath}", sourceFileId, newPath);
|
||||
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> RenameFileAsync(Guid fileId, string newName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await GetFileAsync(fileId, cancellationToken);
|
||||
|
||||
file.Name = newName;
|
||||
file.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Renamed file {FileId} to {NewName}", fileId, newName);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public async Task<long> GetFileSizeAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await GetFileAsync(fileId, cancellationToken);
|
||||
return file.Size;
|
||||
}
|
||||
|
||||
public async Task<string> GetFileHashAsync(Guid fileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await GetFileAsync(fileId, cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(file.ContentHash))
|
||||
{
|
||||
// In a real implementation, this would compute the hash of the file content
|
||||
file.ContentHash = Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..16];
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return file.ContentHash;
|
||||
}
|
||||
|
||||
public async Task<Stream> GetFileThumbnailAsync(Guid fileId, int? width = null, int? height = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would generate or retrieve a thumbnail
|
||||
var placeholder = $"This is a thumbnail for file {fileId} with size {width ?? 0}x{height ?? 0}";
|
||||
var memoryStream = new MemoryStream();
|
||||
var writer = new StreamWriter(memoryStream);
|
||||
await writer.WriteAsync(placeholder);
|
||||
await writer.FlushAsync();
|
||||
memoryStream.Position = 0;
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
public async Task<CloudFile> SetFileVisibilityAsync(Guid fileId, bool isPublic, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = await GetFileAsync(fileId, cancellationToken);
|
||||
|
||||
if (file.IsPublic != isPublic)
|
||||
{
|
||||
file.IsPublic = isPublic;
|
||||
file.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Set visibility of file {FileId} to {Visibility}", fileId, isPublic ? "public" : "private");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_dbContext?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
13
DysonNetwork.Drive/Services/ICacheService.cs
Normal file
13
DysonNetwork.Drive/Services/ICacheService.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DysonNetwork.Drive.Services;
|
||||
|
||||
public interface ICacheService
|
||||
{
|
||||
Task<T?> GetAsync<T>(string key);
|
||||
Task SetAsync<T>(string key, T value, System.TimeSpan? expiry = null);
|
||||
Task RemoveAsync(string key);
|
||||
Task<bool> ExistsAsync(string key);
|
||||
Task<long> IncrementAsync(string key, long value = 1);
|
||||
Task<long> DecrementAsync(string key, long value = 1);
|
||||
}
|
33
DysonNetwork.Drive/TextSanitizer.cs
Normal file
33
DysonNetwork.Drive/TextSanitizer.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
public abstract class TextSanitizer
|
||||
{
|
||||
public static string? Sanitize(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return text;
|
||||
|
||||
// List of control characters to preserve
|
||||
var preserveControlChars = new[] { '\n', '\r', '\t', ' ' };
|
||||
|
||||
var filtered = new StringBuilder();
|
||||
foreach (var ch in text)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
|
||||
|
||||
// Keep whitespace and other specified control characters
|
||||
if (category is not UnicodeCategory.Control || preserveControlChars.Contains(ch))
|
||||
{
|
||||
// Still filter out Format and NonSpacingMark categories
|
||||
if (category is not (UnicodeCategory.Format or UnicodeCategory.NonSpacingMark))
|
||||
{
|
||||
filtered.Append(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered.ToString();
|
||||
}
|
||||
}
|
81
DysonNetwork.Drive/TusService.cs
Normal file
81
DysonNetwork.Drive/TusService.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using tusdotnet.Interfaces;
|
||||
using tusdotnet.Models;
|
||||
using tusdotnet.Models.Configuration;
|
||||
// Using fully qualified names to avoid ambiguity with DysonNetwork.Common.Models
|
||||
using DysonNetwork.Drive.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
public abstract class TusService
|
||||
{
|
||||
public static DefaultTusConfiguration BuildConfiguration(ITusStore store) => new()
|
||||
{
|
||||
Store = store,
|
||||
Events = new Events
|
||||
{
|
||||
OnAuthorizeAsync = async eventContext =>
|
||||
{
|
||||
if (eventContext.Intent == IntentType.DeleteFile)
|
||||
{
|
||||
eventContext.FailRequest(
|
||||
HttpStatusCode.BadRequest,
|
||||
"Deleting files from this endpoint was disabled, please refer to the Dyson Network File API."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var httpContext = eventContext.HttpContext;
|
||||
if (httpContext.Items["CurrentUser"] is not Account.Account user)
|
||||
{
|
||||
eventContext.FailRequest(HttpStatusCode.Unauthorized);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.IsSuperuser)
|
||||
{
|
||||
using var scope = httpContext.RequestServices.CreateScope();
|
||||
var pm = scope.ServiceProvider.GetRequiredService<PermissionService>();
|
||||
var allowed = await pm.HasPermissionAsync($"user:{user.Id}", "global", "files.create");
|
||||
if (!allowed)
|
||||
eventContext.FailRequest(HttpStatusCode.Forbidden);
|
||||
}
|
||||
},
|
||||
OnFileCompleteAsync = async eventContext =>
|
||||
{
|
||||
using var scope = eventContext.HttpContext.RequestServices.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
|
||||
var httpContext = eventContext.HttpContext;
|
||||
if (httpContext.Items["CurrentUser"] is not Account.Account user) return;
|
||||
|
||||
var file = await eventContext.GetFileAsync();
|
||||
var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
|
||||
var fileName = metadata.TryGetValue("filename", out var fn)
|
||||
? fn.GetString(Encoding.UTF8)
|
||||
: "uploaded_file";
|
||||
var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
|
||||
|
||||
var fileStream = await file.GetContentAsync(eventContext.CancellationToken);
|
||||
|
||||
var fileService = services.GetRequiredService<FileService>();
|
||||
var info = await fileService.ProcessNewFileAsync(user, file.Id, fileStream, fileName, contentType);
|
||||
|
||||
using var finalScope = eventContext.HttpContext.RequestServices.CreateScope();
|
||||
var jsonOptions = finalScope.ServiceProvider.GetRequiredService<IOptions<JsonOptions>>().Value
|
||||
.JsonSerializerOptions;
|
||||
var infoJson = JsonSerializer.Serialize(info, jsonOptions);
|
||||
eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson);
|
||||
|
||||
// Dispose the stream after all processing is complete
|
||||
await fileStream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user