diff --git a/DysonNetwork.Common/DysonNetwork.Common.csproj b/DysonNetwork.Common/DysonNetwork.Common.csproj index ee9c09b..e93f7e0 100644 --- a/DysonNetwork.Common/DysonNetwork.Common.csproj +++ b/DysonNetwork.Common/DysonNetwork.Common.csproj @@ -15,6 +15,7 @@ + diff --git a/DysonNetwork.Common/Interfaces/IFileReferenceServiceClient.cs b/DysonNetwork.Common/Interfaces/IFileReferenceServiceClient.cs new file mode 100644 index 0000000..61f9a78 --- /dev/null +++ b/DysonNetwork.Common/Interfaces/IFileReferenceServiceClient.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DysonNetwork.Common.Models; +using NodaTime; + +namespace DysonNetwork.Common.Interfaces +{ + public interface IFileReferenceServiceClient + { + Task CreateReferenceAsync( + string fileId, + string usage, + string resourceId, + Instant? expiredAt = null, + Duration? duration = null); + + Task DeleteReferenceAsync(string referenceId); + Task DeleteResourceReferencesAsync(string resourceId, string? usage = null); + Task> GetFileReferencesAsync(string fileId); + Task> GetResourceReferencesAsync(string resourceId, string? usage = null); + Task HasReferencesAsync(string fileId); + Task UpdateReferenceExpirationAsync(string referenceId, Instant? expiredAt); + } +} diff --git a/DysonNetwork.Common/Interfaces/IFileServiceClient.cs b/DysonNetwork.Common/Interfaces/IFileServiceClient.cs new file mode 100644 index 0000000..b03372f --- /dev/null +++ b/DysonNetwork.Common/Interfaces/IFileServiceClient.cs @@ -0,0 +1,17 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using DysonNetwork.Common.Models; + +namespace DysonNetwork.Common.Interfaces +{ + public interface IFileServiceClient + { + Task GetFileAsync(string fileId); + Task GetFileStreamAsync(string fileId); + Task UploadFileAsync(Stream fileStream, string fileName, string? contentType = null); + Task DeleteFileAsync(string fileId); + Task ProcessImageAsync(Stream imageStream, string fileName, string? contentType = null); + Task GetFileUrl(string fileId, bool useCdn = false); + } +} diff --git a/DysonNetwork.Common/Models/Auth.cs b/DysonNetwork.Common/Models/Auth.cs index f3ddf9c..a3a1064 100644 --- a/DysonNetwork.Common/Models/Auth.cs +++ b/DysonNetwork.Common/Models/Auth.cs @@ -14,9 +14,9 @@ public class AuthSession : ModelBase public Instant? ExpiredAt { get; set; } public Guid AccountId { get; set; } - [JsonIgnore] public Models.Account Account { get; set; } = null!; + [JsonIgnore] public Account Account { get; set; } = null!; public Guid ChallengeId { get; set; } - public AuthChallenge AuthChallenge { get; set; } = null!; + public AuthChallenge Challenge { get; set; } = null!; public Guid? AppId { get; set; } public CustomApp? App { get; set; } } diff --git a/DysonNetwork.Drive/Attributes/RequiredPermissionAttribute.cs b/DysonNetwork.Drive/Attributes/RequiredPermissionAttribute.cs new file mode 100644 index 0000000..671d287 --- /dev/null +++ b/DysonNetwork.Drive/Attributes/RequiredPermissionAttribute.cs @@ -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; + } +} diff --git a/DysonNetwork.Drive/Auth/AuthService.cs b/DysonNetwork.Drive/Auth/AuthService.cs new file mode 100644 index 0000000..9043b20 --- /dev/null +++ b/DysonNetwork.Drive/Auth/AuthService.cs @@ -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 GenerateJwtToken(Account account); + Task GetAuthenticatedAccountAsync(ClaimsPrincipal user); + Task 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 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 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().FindAsync(userId); + } + + public async Task GetAuthenticatedAccountAsync(HttpContext context) + { + return await GetAuthenticatedAccountAsync(context.User); + } +} diff --git a/DysonNetwork.Drive/Auth/Session.cs b/DysonNetwork.Drive/Auth/Session.cs new file mode 100644 index 0000000..d7f72f5 --- /dev/null +++ b/DysonNetwork.Drive/Auth/Session.cs @@ -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; + } +} diff --git a/DysonNetwork.Drive/Clients/FileReferenceServiceClient.cs b/DysonNetwork.Drive/Clients/FileReferenceServiceClient.cs new file mode 100644 index 0000000..0c0e5bc --- /dev/null +++ b/DysonNetwork.Drive/Clients/FileReferenceServiceClient.cs @@ -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 _logger; + private readonly JsonSerializerOptions _jsonOptions; + + public FileReferenceServiceClient(HttpClient httpClient, ILogger 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 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(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> 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>(responseStream, _jsonOptions) + ?? new List(); + } + + public async Task> 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>(responseStream, _jsonOptions) + ?? new List(); + } + + public async Task 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(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); + } + } +} diff --git a/DysonNetwork.Drive/Clients/FileServiceClient.cs b/DysonNetwork.Drive/Clients/FileServiceClient.cs new file mode 100644 index 0000000..a87d4c8 --- /dev/null +++ b/DysonNetwork.Drive/Clients/FileServiceClient.cs @@ -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 _logger; + private readonly JsonSerializerOptions _jsonOptions; + + public FileServiceClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + } + + public async Task 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(stream, _jsonOptions) + ?? throw new InvalidOperationException("Failed to deserialize file response"); + } + + public async Task GetFileStreamAsync(string fileId) + { + var response = await _httpClient.GetAsync($"api/files/{fileId}/download"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStreamAsync(); + } + + public async Task 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(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 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(responseStream, _jsonOptions) + ?? throw new InvalidOperationException("Failed to deserialize image processing response"); + } + + public async Task 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(result, _jsonOptions) ?? string.Empty; + } + + public void Dispose() + { + _httpClient?.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/DysonNetwork.Sphere/Storage/CloudFileUnusedRecyclingJob.cs b/DysonNetwork.Drive/CloudFileUnusedRecyclingJob.cs similarity index 97% rename from DysonNetwork.Sphere/Storage/CloudFileUnusedRecyclingJob.cs rename to DysonNetwork.Drive/CloudFileUnusedRecyclingJob.cs index 6b97a06..5595ddf 100644 --- a/DysonNetwork.Sphere/Storage/CloudFileUnusedRecyclingJob.cs +++ b/DysonNetwork.Drive/CloudFileUnusedRecyclingJob.cs @@ -1,8 +1,10 @@ using Microsoft.EntityFrameworkCore; using NodaTime; using Quartz; +using DysonNetwork.Sphere; +using Microsoft.Extensions.Logging; -namespace DysonNetwork.Sphere.Storage; +namespace DysonNetwork.Drive; public class CloudFileUnusedRecyclingJob( AppDatabase db, diff --git a/DysonNetwork.Drive/Controllers/FileController.cs b/DysonNetwork.Drive/Controllers/FileController.cs new file mode 100644 index 0000000..b33de8c --- /dev/null +++ b/DysonNetwork.Drive/Controllers/FileController.cs @@ -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 _logger; + + public FileController(IFileService fileService, ILogger 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 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 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 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 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 UpdateFileMetadata( + Guid fileId, + [FromBody] Dictionary 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 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 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." }); + } + } + } +} diff --git a/DysonNetwork.Drive/Controllers/FileReferenceController.cs b/DysonNetwork.Drive/Controllers/FileReferenceController.cs new file mode 100644 index 0000000..9602be4 --- /dev/null +++ b/DysonNetwork.Drive/Controllers/FileReferenceController.cs @@ -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 _logger; + + public FileReferenceController( + IFileReferenceService referenceService, + ILogger 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 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 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), StatusCodes.Status200OK)] + public async Task 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), StatusCodes.Status200OK)] + public async Task 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), StatusCodes.Status200OK)] + public async Task 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 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 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 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 UpdateReferenceMetadata( + Guid referenceId, + [FromBody] Dictionary 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 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 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? Metadata { get; set; } + } + + public class UpdateReferenceResourceRequest + { + public string NewResourceId { get; set; } = null!; + public string NewResourceType { get; set; } = null!; + } +} diff --git a/DysonNetwork.Drive/Data/AppDatabase.cs b/DysonNetwork.Drive/Data/AppDatabase.cs new file mode 100644 index 0000000..39bcddf --- /dev/null +++ b/DysonNetwork.Drive/Data/AppDatabase.cs @@ -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 options, IConfiguration configuration) + : base(options) + { + _configuration = configuration; + } + + public DbSet Files { get; set; } = null!; + public DbSet 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(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(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); + } +} diff --git a/DysonNetwork.Drive/DysonNetwork.Drive.csproj b/DysonNetwork.Drive/DysonNetwork.Drive.csproj new file mode 100644 index 0000000..83261f8 --- /dev/null +++ b/DysonNetwork.Drive/DysonNetwork.Drive.csproj @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + net9.0 + enable + enable + + + diff --git a/DysonNetwork.Drive/Extensions/StringExtensions.cs b/DysonNetwork.Drive/Extensions/StringExtensions.cs new file mode 100644 index 0000000..04742ec --- /dev/null +++ b/DysonNetwork.Drive/Extensions/StringExtensions.cs @@ -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(); + } +} diff --git a/DysonNetwork.Sphere/Storage/FileController.cs b/DysonNetwork.Drive/FileController.cs similarity index 88% rename from DysonNetwork.Sphere/Storage/FileController.cs rename to DysonNetwork.Drive/FileController.cs index 58920a0..554c3f2 100644 --- a/DysonNetwork.Sphere/Storage/FileController.cs +++ b/DysonNetwork.Drive/FileController.cs @@ -1,10 +1,14 @@ -using DysonNetwork.Sphere.Permission; 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.Sphere.Storage; +namespace DysonNetwork.Drive; [ApiController] [Route("/files")] @@ -79,7 +83,7 @@ public class FileController( } [HttpGet("{id}/info")] - public async Task> GetFileInfo(string id) + public async Task> GetFileInfo(string id) { var file = await db.Files.FindAsync(id); if (file is null) return NotFound(); @@ -91,7 +95,7 @@ public class FileController( [HttpDelete("{id}")] public async Task DeleteFile(string id) { - if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Models.Account currentUser) return Unauthorized(); var userId = currentUser.Id; var file = await db.Files @@ -110,7 +114,7 @@ public class FileController( [HttpPost("/maintenance/migrateReferences")] [Authorize] - [RequiredPermission("maintenance", "files.references")] + [RequiredPermission("maintenance.files.references")] public async Task MigrateFileReferences() { await rms.ScanAndMigrateReferences(); diff --git a/DysonNetwork.Sphere/Storage/FileExpirationJob.cs b/DysonNetwork.Drive/FileExpirationJob.cs similarity index 96% rename from DysonNetwork.Sphere/Storage/FileExpirationJob.cs rename to DysonNetwork.Drive/FileExpirationJob.cs index 50ccc17..2c4b60c 100644 --- a/DysonNetwork.Sphere/Storage/FileExpirationJob.cs +++ b/DysonNetwork.Drive/FileExpirationJob.cs @@ -1,8 +1,10 @@ using Microsoft.EntityFrameworkCore; using NodaTime; using Quartz; +using DysonNetwork.Sphere; +using Microsoft.Extensions.Logging; -namespace DysonNetwork.Sphere.Storage; +namespace DysonNetwork.Drive; /// /// Job responsible for cleaning up expired file references diff --git a/DysonNetwork.Sphere/Storage/FileReferenceService.cs b/DysonNetwork.Drive/FileReferenceService.cs similarity index 99% rename from DysonNetwork.Sphere/Storage/FileReferenceService.cs rename to DysonNetwork.Drive/FileReferenceService.cs index 1ea92a5..42a42ed 100644 --- a/DysonNetwork.Sphere/Storage/FileReferenceService.cs +++ b/DysonNetwork.Drive/FileReferenceService.cs @@ -1,8 +1,10 @@ using DysonNetwork.Common.Services; using Microsoft.EntityFrameworkCore; using NodaTime; +using DysonNetwork.Sphere; +using DysonNetwork.Common.Models; -namespace DysonNetwork.Sphere.Storage; +namespace DysonNetwork.Drive; public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache) { diff --git a/DysonNetwork.Sphere/Storage/FileService.ReferenceMigration.cs b/DysonNetwork.Drive/FileService.ReferenceMigration.cs similarity index 99% rename from DysonNetwork.Sphere/Storage/FileService.ReferenceMigration.cs rename to DysonNetwork.Drive/FileService.ReferenceMigration.cs index 669b947..520166e 100644 --- a/DysonNetwork.Sphere/Storage/FileService.ReferenceMigration.cs +++ b/DysonNetwork.Drive/FileService.ReferenceMigration.cs @@ -1,8 +1,10 @@ using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using NodaTime; +using DysonNetwork.Common.Models; +using DysonNetwork.Sphere; -namespace DysonNetwork.Sphere.Storage; +namespace DysonNetwork.Drive; public class FileReferenceMigrationService(AppDatabase db) { diff --git a/DysonNetwork.Sphere/Storage/FileService.cs b/DysonNetwork.Drive/FileService.cs similarity index 98% rename from DysonNetwork.Sphere/Storage/FileService.cs rename to DysonNetwork.Drive/FileService.cs index cef86e1..60473ab 100644 --- a/DysonNetwork.Sphere/Storage/FileService.cs +++ b/DysonNetwork.Drive/FileService.cs @@ -1,16 +1,21 @@ using System.Globalization; -using FFMpegCore; using System.Security.Cryptography; -using AngleSharp.Text; +using DysonNetwork.Common.Models; using DysonNetwork.Common.Services; +using DysonNetwork.Sphere; using Microsoft.EntityFrameworkCore; using Minio; using Minio.DataModel.Args; -using NetVips; 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.Sphere.Storage; +namespace DysonNetwork.Drive; public class FileService( AppDatabase db, diff --git a/DysonNetwork.Sphere/Storage/FlushBufferService.cs b/DysonNetwork.Drive/FlushBufferService.cs similarity index 97% rename from DysonNetwork.Sphere/Storage/FlushBufferService.cs rename to DysonNetwork.Drive/FlushBufferService.cs index 43dd6d8..b241c52 100644 --- a/DysonNetwork.Sphere/Storage/FlushBufferService.cs +++ b/DysonNetwork.Drive/FlushBufferService.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -namespace DysonNetwork.Sphere.Storage; +namespace DysonNetwork.Drive; public interface IFlushHandler { diff --git a/DysonNetwork.Sphere/Storage/Handlers/ActionLogFlushHandler.cs b/DysonNetwork.Drive/Handlers/ActionLogFlushHandler.cs similarity index 81% rename from DysonNetwork.Sphere/Storage/Handlers/ActionLogFlushHandler.cs rename to DysonNetwork.Drive/Handlers/ActionLogFlushHandler.cs index e38b6c4..6ae88da 100644 --- a/DysonNetwork.Sphere/Storage/Handlers/ActionLogFlushHandler.cs +++ b/DysonNetwork.Drive/Handlers/ActionLogFlushHandler.cs @@ -1,8 +1,11 @@ using EFCore.BulkExtensions; using Quartz; +using DysonNetwork.Sphere; +using DysonNetwork.Common.Models; +using Microsoft.Extensions.DependencyInjection; -namespace DysonNetwork.Sphere.Storage.Handlers; +namespace DysonNetwork.Drive.Handlers; public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler { diff --git a/DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs b/DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs similarity index 84% rename from DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs rename to DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs index 6caf8eb..81f9d68 100644 --- a/DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs +++ b/DysonNetwork.Drive/Handlers/LastActiveFlushHandler.cs @@ -1,13 +1,16 @@ using Microsoft.EntityFrameworkCore; using NodaTime; using Quartz; +using DysonNetwork.Drive.Auth; +using DysonNetwork.Drive.Models; +using Microsoft.Extensions.DependencyInjection; -namespace DysonNetwork.Sphere.Storage.Handlers; +namespace DysonNetwork.Drive.Handlers; public class LastActiveInfo { - public Auth.Session Session { get; set; } = null!; - public Account.Account Account { get; set; } = null!; + public Session Session { get; set; } = null!; + public Account Account { get; set; } = null!; public Instant SeenAt { get; set; } } @@ -51,7 +54,7 @@ public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHa } } -public class LastActiveFlushJob(FlushBufferService fbs, ActionLogFlushHandler hdl) : IJob +public class LastActiveFlushJob(FlushBufferService fbs, LastActiveFlushHandler hdl) : IJob { public async Task Execute(IJobExecutionContext context) { diff --git a/DysonNetwork.Sphere/Storage/Handlers/MessageReadReceiptFlushHandler.cs b/DysonNetwork.Drive/Handlers/MessageReadReceiptFlushHandler.cs similarity index 90% rename from DysonNetwork.Sphere/Storage/Handlers/MessageReadReceiptFlushHandler.cs rename to DysonNetwork.Drive/Handlers/MessageReadReceiptFlushHandler.cs index 472d19e..5e57e26 100644 --- a/DysonNetwork.Sphere/Storage/Handlers/MessageReadReceiptFlushHandler.cs +++ b/DysonNetwork.Drive/Handlers/MessageReadReceiptFlushHandler.cs @@ -1,11 +1,12 @@ -using DysonNetwork.Common.Models; -using DysonNetwork.Sphere.Chat; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using NodaTime; using Quartz; +using DysonNetwork.Sphere; +using DysonNetwork.Common.Models; +using Microsoft.Extensions.DependencyInjection; -namespace DysonNetwork.Sphere.Storage.Handlers; +namespace DysonNetwork.Drive.Handlers; public class MessageReadReceiptFlushHandler(IServiceProvider serviceProvider) : IFlushHandler { diff --git a/DysonNetwork.Sphere/Storage/Handlers/PostViewFlushHandler.cs b/DysonNetwork.Drive/Handlers/PostViewFlushHandler.cs similarity index 86% rename from DysonNetwork.Sphere/Storage/Handlers/PostViewFlushHandler.cs rename to DysonNetwork.Drive/Handlers/PostViewFlushHandler.cs index b9fb84a..28e9de7 100644 --- a/DysonNetwork.Sphere/Storage/Handlers/PostViewFlushHandler.cs +++ b/DysonNetwork.Drive/Handlers/PostViewFlushHandler.cs @@ -1,13 +1,15 @@ -using DysonNetwork.Common.Services; +using DysonNetwork.Drive.Models; using Microsoft.EntityFrameworkCore; using NodaTime; using Quartz; +using Microsoft.Extensions.DependencyInjection; +using DysonNetwork.Drive.Services; -namespace DysonNetwork.Sphere.Storage.Handlers; +namespace DysonNetwork.Drive.Handlers; -public class PostViewFlushHandler(IServiceProvider serviceProvider) : IFlushHandler +public class PostViewFlushHandler(IServiceProvider serviceProvider) : IFlushHandler { - public async Task FlushAsync(IReadOnlyList items) + public async Task FlushAsync(IReadOnlyList items) { using var scope = serviceProvider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); diff --git a/DysonNetwork.Drive/Interfaces/IFileReferenceService.cs b/DysonNetwork.Drive/Interfaces/IFileReferenceService.cs new file mode 100644 index 0000000..ad1a325 --- /dev/null +++ b/DysonNetwork.Drive/Interfaces/IFileReferenceService.cs @@ -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 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? metadata = null, + CancellationToken cancellationToken = default); + + Task GetReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default); + Task> GetReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default); + Task> GetReferencesForResourceAsync(string resourceId, string resourceType, CancellationToken cancellationToken = default); + Task> GetReferencesOfTypeAsync(string referenceType, CancellationToken cancellationToken = default); + Task DeleteReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default); + Task DeleteReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default); + Task DeleteReferencesForResourceAsync(string resourceId, string resourceType, CancellationToken cancellationToken = default); + Task UpdateReferenceMetadataAsync(Guid referenceId, IDictionary metadata, CancellationToken cancellationToken = default); + Task ReferenceExistsAsync(Guid referenceId, CancellationToken cancellationToken = default); + Task HasReferenceAsync(Guid fileId, string resourceId, string resourceType, string? referenceType = null, CancellationToken cancellationToken = default); + Task UpdateReferenceResourceAsync(Guid referenceId, string newResourceId, string newResourceType, CancellationToken cancellationToken = default); + Task> GetFilesForResourceAsync(string resourceId, string resourceType, string? referenceType = null, CancellationToken cancellationToken = default); + Task> GetFilesForReferenceTypeAsync(string referenceType, CancellationToken cancellationToken = default); +} diff --git a/DysonNetwork.Drive/Interfaces/IFileService.cs b/DysonNetwork.Drive/Interfaces/IFileService.cs new file mode 100644 index 0000000..83005c6 --- /dev/null +++ b/DysonNetwork.Drive/Interfaces/IFileService.cs @@ -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 GetFileAsync(Guid fileId, CancellationToken cancellationToken = default); + Task DownloadFileAsync(Guid fileId, CancellationToken cancellationToken = default); + Task UploadFileAsync(Stream fileStream, string fileName, string contentType, IDictionary? metadata = null, CancellationToken cancellationToken = default); + Task DeleteFileAsync(Guid fileId, CancellationToken cancellationToken = default); + Task UpdateFileMetadataAsync(Guid fileId, IDictionary metadata, CancellationToken cancellationToken = default); + Task FileExistsAsync(Guid fileId, CancellationToken cancellationToken = default); + Task GetFileUrlAsync(Guid fileId, TimeSpan? expiry = null, CancellationToken cancellationToken = default); + Task GetFileThumbnailUrlAsync(Guid fileId, int? width = null, int? height = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default); + Task CopyFileAsync(Guid sourceFileId, string? newName = null, IDictionary? newMetadata = null, CancellationToken cancellationToken = default); + Task MoveFileAsync(Guid sourceFileId, string? newName = null, IDictionary? newMetadata = null, CancellationToken cancellationToken = default); + Task RenameFileAsync(Guid fileId, string newName, CancellationToken cancellationToken = default); + Task GetFileSizeAsync(Guid fileId, CancellationToken cancellationToken = default); + Task GetFileHashAsync(Guid fileId, CancellationToken cancellationToken = default); + Task GetFileThumbnailAsync(Guid fileId, int? width = null, int? height = null, CancellationToken cancellationToken = default); + Task SetFileVisibilityAsync(Guid fileId, bool isPublic, CancellationToken cancellationToken = default); +} diff --git a/DysonNetwork.Drive/Models/Account.cs b/DysonNetwork.Drive/Models/Account.cs new file mode 100644 index 0000000..e430c16 --- /dev/null +++ b/DysonNetwork.Drive/Models/Account.cs @@ -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 Files { get; set; } = new List(); + + // Timestamps + public DateTimeOffset? LastLoginAt { get; set; } + + // Methods + public bool HasPermission(Permission permission) + { + // TODO: Implement actual permission checking logic + return true; + } +} diff --git a/DysonNetwork.Drive/Models/CloudFile.cs b/DysonNetwork.Drive/Models/CloudFile.cs new file mode 100644 index 0000000..5fb6bbe --- /dev/null +++ b/DysonNetwork.Drive/Models/CloudFile.cs @@ -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 References { get; set; } = new List(); + + public void Dispose() + { + ExtendedMetadata?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/DysonNetwork.Drive/Models/CloudFileReference.cs b/DysonNetwork.Drive/Models/CloudFileReference.cs new file mode 100644 index 0000000..8bb9135 --- /dev/null +++ b/DysonNetwork.Drive/Models/CloudFileReference.cs @@ -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); + } +} diff --git a/DysonNetwork.Drive/Models/ModelBase.cs b/DysonNetwork.Drive/Models/ModelBase.cs new file mode 100644 index 0000000..e42637f --- /dev/null +++ b/DysonNetwork.Drive/Models/ModelBase.cs @@ -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; } +} diff --git a/DysonNetwork.Drive/Models/Permission.cs b/DysonNetwork.Drive/Models/Permission.cs new file mode 100644 index 0000000..d4803a1 --- /dev/null +++ b/DysonNetwork.Drive/Models/Permission.cs @@ -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 +} diff --git a/DysonNetwork.Drive/Models/Post.cs b/DysonNetwork.Drive/Models/Post.cs new file mode 100644 index 0000000..e000cac --- /dev/null +++ b/DysonNetwork.Drive/Models/Post.cs @@ -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 Views { get; set; } = new List(); + public virtual ICollection Attachments { get; set; } = new List(); +} + +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; } +} diff --git a/DysonNetwork.Drive/Program.cs b/DysonNetwork.Drive/Program.cs new file mode 100644 index 0000000..351a93b --- /dev/null +++ b/DysonNetwork.Drive/Program.cs @@ -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(SystemClock.Instance); + +// Add database context +builder.Services.AddDbContext((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(); +builder.Services.AddScoped(); + +// 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() + } + }); +}); + +// Configure HTTP client for external services +builder.Services.AddHttpClient(); + +// Add health checks +builder.Services.AddHealthChecks() + .AddDbContextCheck(); + +// 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(); + if (dbContext.Database.IsNpgsql()) + { + await dbContext.Database.MigrateAsync(); + app.Logger.LogInformation("Database migrations applied successfully."); + } + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + 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(); diff --git a/DysonNetwork.Drive/Properties/launchSettings.json b/DysonNetwork.Drive/Properties/launchSettings.json new file mode 100644 index 0000000..ff99ba7 --- /dev/null +++ b/DysonNetwork.Drive/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "DysonNetwork.Drive": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5073", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DysonNetwork.Drive/Services/FileReferenceService.cs b/DysonNetwork.Drive/Services/FileReferenceService.cs new file mode 100644 index 0000000..c1146a6 --- /dev/null +++ b/DysonNetwork.Drive/Services/FileReferenceService.cs @@ -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 _logger; + private bool _disposed = false; + + public FileReferenceService( + AppDatabase dbContext, + IFileService fileService, + IClock clock, + ILogger 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 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? 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 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> GetReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default) + { + return await _dbContext.FileReferences + .AsNoTracking() + .Where(r => r.FileId == fileId && r.IsActive) + .ToListAsync(cancellationToken); + } + + public async Task> 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> GetReferencesOfTypeAsync( + string referenceType, + CancellationToken cancellationToken = default) + { + return await _dbContext.FileReferences + .AsNoTracking() + .Where(r => r.ReferenceType == referenceType && r.IsActive) + .ToListAsync(cancellationToken); + } + + public async Task 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 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 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 UpdateReferenceMetadataAsync( + Guid referenceId, + IDictionary 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 ReferenceExistsAsync(Guid referenceId, CancellationToken cancellationToken = default) + { + return await _dbContext.FileReferences + .AsNoTracking() + .AnyAsync(r => r.Id == referenceId && r.IsActive, cancellationToken); + } + + public async Task 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 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> 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> 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); + } +} diff --git a/DysonNetwork.Drive/Services/FileService.cs b/DysonNetwork.Drive/Services/FileService.cs new file mode 100644 index 0000000..afeb92e --- /dev/null +++ b/DysonNetwork.Drive/Services/FileService.cs @@ -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 _logger; + private readonly AppDatabase _dbContext; + private readonly IClock _clock; + private bool _disposed = false; + + public FileService(AppDatabase dbContext, IClock clock, ILogger 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 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 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 UploadFileAsync( + Stream fileStream, + string fileName, + string contentType, + IDictionary? 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 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 UpdateFileMetadataAsync(Guid fileId, IDictionary 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 FileExistsAsync(Guid fileId, CancellationToken cancellationToken = default) + { + return _dbContext.Files + .AsNoTracking() + .AnyAsync(f => f.Id == fileId && !f.IsDeleted, cancellationToken); + } + + public Task 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 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 CopyFileAsync(Guid sourceFileId, string? newName = null, IDictionary? 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 MoveFileAsync(Guid sourceFileId, string? newName = null, IDictionary? 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 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 GetFileSizeAsync(Guid fileId, CancellationToken cancellationToken = default) + { + var file = await GetFileAsync(fileId, cancellationToken); + return file.Size; + } + + public async Task 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 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 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); + } +} diff --git a/DysonNetwork.Drive/Services/ICacheService.cs b/DysonNetwork.Drive/Services/ICacheService.cs new file mode 100644 index 0000000..f6a9e13 --- /dev/null +++ b/DysonNetwork.Drive/Services/ICacheService.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace DysonNetwork.Drive.Services; + +public interface ICacheService +{ + Task GetAsync(string key); + Task SetAsync(string key, T value, System.TimeSpan? expiry = null); + Task RemoveAsync(string key); + Task ExistsAsync(string key); + Task IncrementAsync(string key, long value = 1); + Task DecrementAsync(string key, long value = 1); +} diff --git a/DysonNetwork.Sphere/Storage/TextSanitizer.cs b/DysonNetwork.Drive/TextSanitizer.cs similarity index 96% rename from DysonNetwork.Sphere/Storage/TextSanitizer.cs rename to DysonNetwork.Drive/TextSanitizer.cs index 82a45c6..69ebe68 100644 --- a/DysonNetwork.Sphere/Storage/TextSanitizer.cs +++ b/DysonNetwork.Drive/TextSanitizer.cs @@ -1,7 +1,7 @@ using System.Globalization; using System.Text; -namespace DysonNetwork.Sphere.Storage; +namespace DysonNetwork.Drive; public abstract class TextSanitizer { diff --git a/DysonNetwork.Sphere/Storage/TusService.cs b/DysonNetwork.Drive/TusService.cs similarity index 93% rename from DysonNetwork.Sphere/Storage/TusService.cs rename to DysonNetwork.Drive/TusService.cs index a83ee83..9464d00 100644 --- a/DysonNetwork.Sphere/Storage/TusService.cs +++ b/DysonNetwork.Drive/TusService.cs @@ -1,14 +1,17 @@ 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.Sphere.Storage; +namespace DysonNetwork.Drive; public abstract class TusService { diff --git a/DysonNetwork.Pass/Data/PassDatabase.cs b/DysonNetwork.Pass/Data/PassDatabase.cs index 38b4c9f..90bfc09 100644 --- a/DysonNetwork.Pass/Data/PassDatabase.cs +++ b/DysonNetwork.Pass/Data/PassDatabase.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using DysonNetwork.Common.Models; -using DysonNetwork.Pass.Permission; +using DysonNetwork.Sphere.Permission; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using NodaTime; diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index b1d44db..7f57ec8 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -21,8 +21,8 @@ - - + + @@ -44,6 +44,8 @@ + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Features/Account/Controllers/AccountCurrentController.cs b/DysonNetwork.Pass/Features/Account/Controllers/AccountCurrentController.cs index 27117f4..2b8bee7 100644 --- a/DysonNetwork.Pass/Features/Account/Controllers/AccountCurrentController.cs +++ b/DysonNetwork.Pass/Features/Account/Controllers/AccountCurrentController.cs @@ -46,7 +46,7 @@ public class AccountCurrentController( [HttpPatch] public async Task> UpdateBasicInfo([FromBody] BasicInfoRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); @@ -77,7 +77,7 @@ public class AccountCurrentController( [HttpPatch("profile")] public async Task> UpdateProfile([FromBody] ProfileRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var userId = currentUser.Id; var profile = await db.AccountProfiles @@ -162,7 +162,7 @@ public class AccountCurrentController( [HttpDelete] public async Task RequestDeleteAccount() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); try { @@ -179,7 +179,7 @@ public class AccountCurrentController( [HttpGet("statuses")] public async Task> GetCurrentStatus() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var status = await events.GetStatus(currentUser.Id); return Ok(status); } @@ -188,7 +188,7 @@ public class AccountCurrentController( [RequiredPermission("global", "accounts.statuses.update")] public async Task> UpdateStatus([FromBody] AccountController.StatusRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var now = SystemClock.Instance.GetCurrentInstant(); var status = await db.AccountStatuses @@ -212,10 +212,10 @@ public class AccountCurrentController( } [HttpPost("statuses")] - [RequiredPermission("global", "accounts.statuses.create")] + [DysonNetwork.Sphere.Permission.RequiredPermission("global", "accounts.statuses.create")] public async Task> CreateStatus([FromBody] AccountController.StatusRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var status = new Status { @@ -233,7 +233,7 @@ public class AccountCurrentController( [HttpDelete("me/statuses")] public async Task DeleteStatus() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var now = SystemClock.Instance.GetCurrentInstant(); var status = await db.AccountStatuses @@ -250,7 +250,7 @@ public class AccountCurrentController( [HttpGet("check-in")] public async Task> GetCheckInResult() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var userId = currentUser.Id; var now = SystemClock.Instance.GetCurrentInstant(); @@ -270,7 +270,7 @@ public class AccountCurrentController( [HttpPost("check-in")] public async Task> DoCheckIn([FromBody] string? captchaToken) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var isAvailable = await events.CheckInDailyIsAvailable(currentUser); if (!isAvailable) @@ -297,7 +297,7 @@ public class AccountCurrentController( public async Task>> GetEventCalendar([FromQuery] int? month, [FromQuery] int? year) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; month ??= currentDate.Month; @@ -318,7 +318,7 @@ public class AccountCurrentController( [FromQuery] int offset = 0 ) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var query = db.ActionLogs .Where(log => log.AccountId == currentUser.Id) @@ -338,7 +338,7 @@ public class AccountCurrentController( [HttpGet("factors")] public async Task>> GetAuthFactors() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var factors = await db.AccountAuthFactors .Include(f => f.Account) @@ -358,7 +358,7 @@ public class AccountCurrentController( [Authorize] public async Task> CreateAuthFactor([FromBody] AuthFactorRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) return BadRequest($"Auth factor with type {request.Type} is already exists."); @@ -370,7 +370,7 @@ public class AccountCurrentController( [Authorize] public async Task> EnableAuthFactor(Guid id, [FromBody] string? code) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var factor = await db.AccountAuthFactors .Where(f => f.AccountId == currentUser.Id && f.Id == id) @@ -392,7 +392,7 @@ public class AccountCurrentController( [Authorize] public async Task> DisableAuthFactor(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var factor = await db.AccountAuthFactors .Where(f => f.AccountId == currentUser.Id && f.Id == id) @@ -414,7 +414,7 @@ public class AccountCurrentController( [Authorize] public async Task> DeleteAuthFactor(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var factor = await db.AccountAuthFactors .Where(f => f.AccountId == currentUser.Id && f.Id == id) @@ -445,7 +445,7 @@ public class AccountCurrentController( [Authorize] public async Task>> GetDevices() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser || + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser || HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); @@ -475,13 +475,13 @@ public class AccountCurrentController( [HttpGet("sessions")] [Authorize] - public async Task>> GetSessions( + public async Task>> GetSessions( [FromQuery] int take = 20, [FromQuery] int offset = 0 ) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser || - HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser || + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); var query = db.AuthSessions .Include(session => session.Account) @@ -505,7 +505,7 @@ public class AccountCurrentController( [Authorize] public async Task> DeleteSession(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); try { @@ -522,7 +522,7 @@ public class AccountCurrentController( [Authorize] public async Task> DeleteCurrentSession() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser || + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser || HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); try @@ -537,9 +537,9 @@ public class AccountCurrentController( } [HttpPatch("sessions/{id:guid}/label")] - public async Task> UpdateSessionLabel(Guid id, [FromBody] string label) + public async Task> UpdateSessionLabel(Guid id, [FromBody] string label) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); try { @@ -553,9 +553,9 @@ public class AccountCurrentController( } [HttpPatch("sessions/current/label")] - public async Task> UpdateCurrentSessionLabel([FromBody] string label) + public async Task> UpdateCurrentSessionLabel([FromBody] string label) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser || + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser || HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); try @@ -573,7 +573,7 @@ public class AccountCurrentController( [Authorize] public async Task>> GetContacts() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var contacts = await db.AccountContacts .Where(c => c.AccountId == currentUser.Id) @@ -592,7 +592,7 @@ public class AccountCurrentController( [Authorize] public async Task> CreateContact([FromBody] AccountContactRequest request) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); try { @@ -609,7 +609,7 @@ public class AccountCurrentController( [Authorize] public async Task> VerifyContact(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var contact = await db.AccountContacts .Where(c => c.AccountId == currentUser.Id && c.Id == id) @@ -631,7 +631,7 @@ public class AccountCurrentController( [Authorize] public async Task> SetPrimaryContact(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var contact = await db.AccountContacts .Where(c => c.AccountId == currentUser.Id && c.Id == id) @@ -653,7 +653,7 @@ public class AccountCurrentController( [Authorize] public async Task> DeleteContact(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var contact = await db.AccountContacts .Where(c => c.AccountId == currentUser.Id && c.Id == id) @@ -676,7 +676,7 @@ public class AccountCurrentController( [Authorize] public async Task>> GetBadges() { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); var badges = await db.Badges .Where(b => b.AccountId == currentUser.Id) @@ -688,7 +688,7 @@ public class AccountCurrentController( [Authorize] public async Task> ActivateBadge(Guid id) { - if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized(); try { diff --git a/DysonNetwork.Pass/Features/Account/Controllers/NotificationController.cs b/DysonNetwork.Pass/Features/Account/Controllers/NotificationController.cs index f90b3b3..e4b1b44 100644 --- a/DysonNetwork.Pass/Features/Account/Controllers/NotificationController.cs +++ b/DysonNetwork.Pass/Features/Account/Controllers/NotificationController.cs @@ -140,7 +140,7 @@ public class NotificationController(PassDatabase db, NotificationService nty) : [HttpPost("send")] [Authorize] - [RequiredPermission("global", "notifications.send")] + [DysonNetwork.Sphere.Permission.RequiredPermission("global", "notifications.send")] public async Task SendNotification( [FromBody] NotificationWithAimRequest request, [FromQuery] bool save = false diff --git a/DysonNetwork.Pass/Features/Account/Services/AccountService.cs b/DysonNetwork.Pass/Features/Account/Services/AccountService.cs index 6803109..1d4430a 100644 --- a/DysonNetwork.Pass/Features/Account/Services/AccountService.cs +++ b/DysonNetwork.Pass/Features/Account/Services/AccountService.cs @@ -3,7 +3,7 @@ using DysonNetwork.Pass.Features.Auth; using DysonNetwork.Pass.Features.Auth.OpenId; using DysonNetwork.Pass.Email; using DysonNetwork.Pass.Localization; -using DysonNetwork.Pass.Permission; +using DysonNetwork.Sphere.Permission; using DysonNetwork.Pass.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; diff --git a/DysonNetwork.Pass/Features/Account/Services/RelationshipService.cs b/DysonNetwork.Pass/Features/Account/Services/RelationshipService.cs index 22771d8..3434b07 100644 --- a/DysonNetwork.Pass/Features/Account/Services/RelationshipService.cs +++ b/DysonNetwork.Pass/Features/Account/Services/RelationshipService.cs @@ -3,7 +3,7 @@ using DysonNetwork.Pass.Storage; using Microsoft.EntityFrameworkCore; using NodaTime; using DysonNetwork.Common.Models; -using DysonNetwork.Pass.Permission; +using DysonNetwork.Sphere.Permission; namespace DysonNetwork.Pass.Features.Account; diff --git a/DysonNetwork.Pass/Features/Auth/Controllers/AuthController.cs b/DysonNetwork.Pass/Features/Auth/Controllers/AuthController.cs index 1387e48..1125127 100644 --- a/DysonNetwork.Pass/Features/Auth/Controllers/AuthController.cs +++ b/DysonNetwork.Pass/Features/Auth/Controllers/AuthController.cs @@ -218,7 +218,7 @@ public class AuthController( if (session is not null) return BadRequest("Session already exists for this challenge."); - session = new Session + var session = new AuthSession { LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), diff --git a/DysonNetwork.Pass/Features/Auth/Services/Auth.cs b/DysonNetwork.Pass/Features/Auth/Services/Auth.cs index 64444e4..f1cb516 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/Auth.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/Auth.cs @@ -3,11 +3,14 @@ using System.Security.Cryptography; using System.Text.Encodings.Web; using DysonNetwork.Pass.Features.Account; using DysonNetwork.Pass.Features.Auth.OidcProvider.Services; -using DysonNetwork.Pass.Storage; -using DysonNetwork.Pass.Storage.Handlers; +using DysonNetwork.Common.Services; +using DysonNetwork.Drive.Handlers; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; -using SystemClock = NodaTime.SystemClock; +using NodaTime; +using DysonNetwork.Pass.Data; +using DysonNetwork.Common.Models; +using DysonNetwork.Drive; namespace DysonNetwork.Pass.Features.Auth.Services; @@ -57,14 +60,14 @@ public class DysonTokenAuthHandler( try { - var now = SystemClock.Instance.GetCurrentInstant(); + var now = NodaTime.SystemClock.Instance.GetCurrentInstant(); // Validate token and extract session ID if (!ValidateToken(tokenInfo.Token, out var sessionId)) return AuthenticateResult.Fail("Invalid token."); // Try to get session from cache first - var session = await cache.GetAsync($"{AuthCachePrefix}{sessionId}"); + var session = await cache.GetAsync($"{AuthCachePrefix}{sessionId}"); // If not in cache, load from database if (session is null) @@ -126,7 +129,7 @@ public class DysonTokenAuthHandler( { Account = session.Account, Session = session, - SeenAt = SystemClock.Instance.GetCurrentInstant(), + SeenAt = NodaTime.SystemClock.Instance.GetCurrentInstant(), }; fbs.Enqueue(lastInfo); diff --git a/DysonNetwork.Pass/Features/Auth/Services/AuthService.cs b/DysonNetwork.Pass/Features/Auth/Services/AuthService.cs index b11c151..dd0f123 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/AuthService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/AuthService.cs @@ -5,6 +5,7 @@ using DysonNetwork.Pass.Storage; using Microsoft.EntityFrameworkCore; using NodaTime; using DysonNetwork.Pass.Data; +using DysonNetwork.Common.Models; namespace DysonNetwork.Pass.Features.Auth; diff --git a/DysonNetwork.Pass/Features/Auth/Services/CompactTokenService.cs b/DysonNetwork.Pass/Features/Auth/Services/CompactTokenService.cs index 01378a6..5c19897 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/CompactTokenService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/CompactTokenService.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using DysonNetwork.Common.Models; namespace DysonNetwork.Pass.Features.Auth; @@ -7,7 +8,7 @@ public class CompactTokenService(IConfiguration config) private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"] ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing"); - public string CreateToken(Session session) + public string CreateToken(AuthSession session) { // Load the private key for signing var privateKeyPem = File.ReadAllText(_privateKeyPath); diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/AppleOidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/AppleOidcService.cs index 3bba22b..92d2113 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/AppleOidcService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/AppleOidcService.cs @@ -5,6 +5,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using DysonNetwork.Common.Services; using Microsoft.IdentityModel.Tokens; +using DysonNetwork.Pass.Data; +using DysonNetwork.Sphere; namespace DysonNetwork.Pass.Features.Auth.OpenId; @@ -14,11 +16,12 @@ namespace DysonNetwork.Pass.Features.Auth.OpenId; public class AppleOidcService( IConfiguration configuration, IHttpClientFactory httpClientFactory, - PassDatabase db, + PassDatabase passDb, + AppDatabase sphereDb, AuthService auth, ICacheService cache ) - : OidcService(configuration, httpClientFactory, db, auth, cache) + : OidcService(configuration, httpClientFactory, passDb, sphereDb, auth, cache) { private readonly IConfiguration _configuration = configuration; private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/DiscordOidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/DiscordOidcService.cs index 7447b0e..8c318a9 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/DiscordOidcService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/DiscordOidcService.cs @@ -1,17 +1,20 @@ using System.Net.Http.Json; using System.Text.Json; -using DysonNetwork.Pass.Storage; +using DysonNetwork.Common.Services; +using DysonNetwork.Pass.Data; +using DysonNetwork.Sphere; namespace DysonNetwork.Pass.Features.Auth.OpenId; public class DiscordOidcService( IConfiguration configuration, IHttpClientFactory httpClientFactory, - PassDatabase db, + PassDatabase passDb, + AppDatabase sphereDb, AuthService auth, ICacheService cache ) - : OidcService(configuration, httpClientFactory, db, auth, cache) + : OidcService(configuration, httpClientFactory, passDb, sphereDb, auth, cache) { public override string ProviderName => "Discord"; protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/GitHubOidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/GitHubOidcService.cs index fd7269a..7eea0d5 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/GitHubOidcService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/GitHubOidcService.cs @@ -1,17 +1,20 @@ using System.Net.Http.Json; using System.Text.Json; -using DysonNetwork.Pass.Storage; +using DysonNetwork.Common.Services; +using DysonNetwork.Pass.Data; +using DysonNetwork.Sphere; namespace DysonNetwork.Pass.Features.Auth.OpenId; public class GitHubOidcService( IConfiguration configuration, IHttpClientFactory httpClientFactory, - PassDatabase db, + PassDatabase passDb, + AppDatabase sphereDb, AuthService auth, ICacheService cache ) - : OidcService(configuration, httpClientFactory, db, auth, cache) + : OidcService(configuration, httpClientFactory, passDb, sphereDb, auth, cache) { public override string ProviderName => "GitHub"; protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint @@ -77,7 +80,7 @@ public class GitHubOidcService( var client = HttpClientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); request.Headers.Add("Authorization", $"Bearer {accessToken}"); - request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); + request.Headers.Add("User-Agent", "DysonNetwork.Drive"); var response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); @@ -109,7 +112,7 @@ public class GitHubOidcService( var client = HttpClientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails"); request.Headers.Add("Authorization", $"Bearer {accessToken}"); - request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); + request.Headers.Add("User-Agent", "DysonNetwork.Drive"); var response = await client.SendAsync(request); if (!response.IsSuccessStatusCode) return null; diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/GoogleOidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/GoogleOidcService.cs index fa13a71..6744cb0 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/GoogleOidcService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/GoogleOidcService.cs @@ -4,17 +4,20 @@ using System.Security.Cryptography; using System.Text; using DysonNetwork.Common.Services; using Microsoft.IdentityModel.Tokens; +using DysonNetwork.Pass.Data; +using DysonNetwork.Sphere; namespace DysonNetwork.Pass.Features.Auth.OpenId; public class GoogleOidcService( IConfiguration configuration, IHttpClientFactory httpClientFactory, - PassDatabase db, + PassDatabase passDb, + AppDatabase sphereDb, AuthService auth, ICacheService cache ) - : OidcService(configuration, httpClientFactory, db, auth, cache) + : OidcService(configuration, httpClientFactory, passDb, sphereDb, auth, cache) { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/MicrosoftOidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/MicrosoftOidcService.cs index 4ccbcfe..455a990 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/MicrosoftOidcService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/MicrosoftOidcService.cs @@ -1,17 +1,20 @@ using System.Net.Http.Json; using System.Text.Json; -using DysonNetwork.Pass.Storage; +using DysonNetwork.Common.Services; +using DysonNetwork.Pass.Data; +using DysonNetwork.Sphere; namespace DysonNetwork.Pass.Features.Auth.OpenId; public class MicrosoftOidcService( IConfiguration configuration, IHttpClientFactory httpClientFactory, - PassDatabase db, + PassDatabase passDb, + AppDatabase sphereDb, AuthService auth, ICacheService cache ) - : OidcService(configuration, httpClientFactory, db, auth, cache) + : OidcService(configuration, httpClientFactory, passDb, sphereDb, auth, cache) { public override string ProviderName => "Microsoft"; diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs index b8c9fa5..f4d8361 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcController.cs @@ -1,8 +1,10 @@ -using DysonNetwork.Common.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using NodaTime; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; +using DysonNetwork.Sphere; namespace DysonNetwork.Pass.Features.Auth.OpenId; @@ -10,7 +12,8 @@ namespace DysonNetwork.Pass.Features.Auth.OpenId; [Route("/auth/login")] public class OidcController( IServiceProvider serviceProvider, - PassDatabase db, + PassDatabase passDb, + AppDatabase sphereDb, AccountService accounts, ICacheService cache ) @@ -31,7 +34,7 @@ public class OidcController( var oidcService = GetOidcService(provider); // If the user is already authenticated, treat as an account connection request - if (HttpContext.Items["CurrentUser"] is Models.Account currentUser) + if (HttpContext.Items["CurrentUser"] is Account currentUser) { var state = Guid.NewGuid().ToString(); var nonce = Guid.NewGuid().ToString(); @@ -67,7 +70,7 @@ public class OidcController( /// Handles Apple authentication directly from mobile apps /// [HttpPost("apple/mobile")] - public async Task> AppleMobileLogin( + public async Task> AppleMobileSignIn( [FromBody] AppleMobileSignInRequest request) { try @@ -124,7 +127,7 @@ public class OidcController( }; } - private async Task FindOrCreateAccount(OidcUserInfo userInfo, string provider) + private async Task FindOrCreateAccount(OidcUserInfo userInfo, string provider) { if (string.IsNullOrEmpty(userInfo.Email)) throw new ArgumentException("Email is required for account creation"); @@ -134,15 +137,16 @@ public class OidcController( if (existingAccount != null) { // Check if this provider connection already exists - var existingConnection = await db.AccountConnections - .FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id && - c.Provider == provider && - c.ProvidedIdentifier == userInfo.UserId); + var existingConnection = await passDb.AccountConnections + .FirstOrDefaultAsync(c => c.Provider == provider && + c.ProvidedIdentifier == userInfo.UserId && + c.AccountId == existingAccount.Id + ); // If no connection exists, create one if (existingConnection != null) { - await db.AccountConnections + await passDb.AccountConnections .Where(c => c.AccountId == existingAccount.Id && c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId) @@ -164,8 +168,8 @@ public class OidcController( Meta = userInfo.ToMetadata() }; - await db.AccountConnections.AddAsync(connection); - await db.SaveChangesAsync(); + await passDb.AccountConnections.AddAsync(connection); + await passDb.SaveChangesAsync(); return existingAccount; } @@ -185,8 +189,8 @@ public class OidcController( Meta = userInfo.ToMetadata() }; - db.AccountConnections.Add(newConnection); - await db.SaveChangesAsync(); + await passDb.AccountConnections.Add(newConnection); + await passDb.SaveChangesAsync(); return newAccount; } diff --git a/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcService.cs b/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcService.cs index 7a42494..dcedc9a 100644 --- a/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcService.cs +++ b/DysonNetwork.Pass/Features/Auth/Services/OpenId/OidcService.cs @@ -5,6 +5,8 @@ using DysonNetwork.Common.Services; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using NodaTime; +using DysonNetwork.Common.Models; +using DysonNetwork.Pass.Data; namespace DysonNetwork.Pass.Features.Auth.OpenId; @@ -21,7 +23,7 @@ public abstract class OidcService( { protected readonly IConfiguration Configuration = configuration; protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory; - protected readonly AppDatabase Db = db; + protected readonly PassDatabase Db = db; /// /// Gets the unique identifier for this provider diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs index 61a34a5..f1b98fd 100644 --- a/DysonNetwork.Pass/Program.cs +++ b/DysonNetwork.Pass/Program.cs @@ -14,7 +14,7 @@ using NodaTime.Serialization.SystemTextJson; using System.Text; using DysonNetwork.Pass.Email; using DysonNetwork.Pass.Developer; -using DysonNetwork.Pass.Features.Account.DysonNetwork.Pass.Features.Account; +using DysonNetwork.Pass.Features.Account; using DysonNetwork.Pass.Features.Account.Services; using DysonNetwork.Pass.Permission; using Quartz; diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs deleted file mode 100644 index fbb4286..0000000 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ /dev/null @@ -1,180 +0,0 @@ -using DysonNetwork.Common.Models; -using DysonNetwork.Sphere.Permission; -using DysonNetwork.Sphere.Realm; -using DysonNetwork.Sphere.Sticker; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Query; - -namespace DysonNetwork.Sphere; - -public class AppDatabase( - DbContextOptions options, - IConfiguration configuration -) : DbContext(options) -{ - public DbSet Files { get; set; } - public DbSet FileReferences { get; set; } - - public DbSet Publishers { get; set; } - - public DbSet PublisherFeatures { get; set; } - - public DbSet Posts { get; set; } - public DbSet PostReactions { get; set; } - public DbSet PostTags { get; set; } - public DbSet PostCategories { get; set; } - public DbSet PostCollections { get; set; } - - public DbSet Realms { get; set; } - public DbSet RealmMembers { get; set; } - public DbSet Tags { get; set; } - public DbSet RealmTags { get; set; } - - public DbSet ChatRooms { get; set; } - public DbSet ChatMembers { get; set; } - public DbSet ChatMessages { get; set; } - public DbSet ChatRealtimeCall { get; set; } - public DbSet ChatReactions { get; set; } - - public DbSet Stickers { get; set; } - public DbSet StickerPacks { get; set; } - - public DbSet Wallets { get; set; } - public DbSet WalletPockets { get; set; } - public DbSet PaymentOrders { get; set; } - public DbSet PaymentTransactions { get; set; } - - public DbSet CustomApps { get; set; } - public DbSet CustomAppSecrets { get; set; } - - public DbSet WalletSubscriptions { get; set; } - public DbSet WalletCoupons { get; set; } - public DbSet WebArticles { get; set; } - public DbSet WebFeeds { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql( - configuration.GetConnectionString("App"), - opt => opt - .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) - .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) - .UseNetTopologySuite() - .UseNodaTime() - ).UseSnakeCaseNamingConvention(); - - - - base.OnConfiguring(optionsBuilder); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity() - .HasKey(pm => new { pm.PublisherId, pm.AccountId }); - modelBuilder.Entity() - .HasOne(pm => pm.Publisher) - .WithMany(p => p.Members) - .HasForeignKey(pm => pm.PublisherId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) - .HasIndex(p => p.SearchVector) - .HasMethod("GIN"); - - modelBuilder.Entity() - .HasIndex(s => s.Secret) - .IsUnique(); - - modelBuilder.Entity() - .HasMany(c => c.Secrets) - .WithOne(s => s.App) - .HasForeignKey(s => s.AppId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasOne(p => p.RepliedPost) - .WithMany() - .HasForeignKey(p => p.RepliedPostId) - .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(p => p.ForwardedPost) - .WithMany() - .HasForeignKey(p => p.ForwardedPostId) - .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasMany(p => p.Tags) - .WithMany(t => t.Posts) - .UsingEntity(j => j.ToTable("post_tag_links")); - modelBuilder.Entity() - .HasMany(p => p.Categories) - .WithMany(c => c.Posts) - .UsingEntity(j => j.ToTable("post_category_links")); - modelBuilder.Entity() - .HasMany(p => p.Collections) - .WithMany(c => c.Posts) - .UsingEntity(j => j.ToTable("post_collection_links")); - - modelBuilder.Entity() - .HasKey(pm => new { pm.Id }); - modelBuilder.Entity() - .HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId }); - - - modelBuilder.Entity() - .HasOne(m => m.ForwardedMessage) - .WithMany() - .HasForeignKey(m => m.ForwardedMessageId) - .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(m => m.RepliedMessage) - .WithMany() - .HasForeignKey(m => m.RepliedMessageId) - .OnDelete(DeleteBehavior.Restrict); - - - - modelBuilder.Entity() - .HasIndex(f => f.Url) - .IsUnique(); - - modelBuilder.Entity() - .HasIndex(a => a.Url) - .IsUnique(); - - - -public static class OptionalQueryExtensions -{ - public static IQueryable If( - this IQueryable source, - bool condition, - Func, IQueryable> transform - ) - { - return condition ? transform(source) : source; - } - - public static IQueryable If( - this IIncludableQueryable source, - bool condition, - Func, IQueryable> transform - ) - where T : class - { - return condition ? transform(source) : source; - } - - public static IQueryable If( - this IIncludableQueryable> source, - bool condition, - Func>, IQueryable> transform - ) - where T : class - { - return condition ? transform(source) : source; - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Chat/ChatRoomController.cs b/DysonNetwork.Sphere/Chat/ChatRoomController.cs index fc8e348..c96c506 100644 --- a/DysonNetwork.Sphere/Chat/ChatRoomController.cs +++ b/DysonNetwork.Sphere/Chat/ChatRoomController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; +using DysonNetwork.Common.Interfaces; using DysonNetwork.Common.Models; using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Permission; @@ -16,7 +17,7 @@ namespace DysonNetwork.Sphere.Chat; [Route("/chat")] public class ChatRoomController( AppDatabase db, - FileReferenceService fileRefService, + IFileReferenceServiceClient fileRefService, ChatRoomService crs, RealmService rs, ActionLogService als, @@ -272,12 +273,12 @@ public class ChatRoomController( if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); // Remove old references for pictures - await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.picture"); + await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat-room.picture"); // Add a new reference await fileRefService.CreateReferenceAsync( - picture.Id, - "chat.room.picture", + picture.Id.ToString(), + "chat-room.picture", chatRoom.ResourceIdentifier ); @@ -290,12 +291,12 @@ public class ChatRoomController( if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); // Remove old references for backgrounds - await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat.room.background"); + await fileRefService.DeleteResourceReferencesAsync(chatRoom.ResourceIdentifier, "chat-room.background"); // Add a new reference await fileRefService.CreateReferenceAsync( - background.Id, - "chat.room.background", + background.Id.ToString(), + "chat-room.background", chatRoom.ResourceIdentifier ); diff --git a/DysonNetwork.Sphere/Chat/ChatService.cs b/DysonNetwork.Sphere/Chat/ChatService.cs index 32ee092..db85400 100644 --- a/DysonNetwork.Sphere/Chat/ChatService.cs +++ b/DysonNetwork.Sphere/Chat/ChatService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using DysonNetwork.Pass.Features.Account; +using DysonNetwork.Common.Interfaces; using DysonNetwork.Common.Models; using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Connection; @@ -11,7 +12,7 @@ namespace DysonNetwork.Sphere.Chat; public partial class ChatService( AppDatabase db, - FileReferenceService fileRefService, + IFileReferenceServiceClient fileRefService, IServiceScopeFactory scopeFactory, IRealtimeService realtime, ILogger logger @@ -162,10 +163,9 @@ public partial class ChatService( foreach (var file in files) { await fileRefService.CreateReferenceAsync( - file.Id, + file.Id.ToString(), ChatFileUsageIdentifier, - messageResourceId, - duration: Duration.FromDays(30) + messageResourceId ); } } diff --git a/DysonNetwork.Sphere/Data/AppDatabase.cs b/DysonNetwork.Sphere/Data/AppDatabase.cs new file mode 100644 index 0000000..dbca5e5 --- /dev/null +++ b/DysonNetwork.Sphere/Data/AppDatabase.cs @@ -0,0 +1,59 @@ +using DysonNetwork.Common.Models; +using DysonNetwork.Sphere.Realm; +using DysonNetwork.Sphere.Sticker; +using DysonNetwork.Sphere.Connection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.Configuration; + +namespace DysonNetwork.Sphere.Data; + +public class AppDatabase : DbContext +{ + private readonly IConfiguration _configuration; + + public AppDatabase(DbContextOptions options, IConfiguration configuration) + : base(options) + { + _configuration = configuration; + } + + public DbSet Files { get; set; } = null!; + public DbSet FileReferences { get; set; } = null!; + public DbSet Publishers { get; set; } = null!; + public DbSet PublisherFeatures { get; set; } = null!; + public DbSet Posts { get; set; } = null!; + public DbSet PostReactions { get; set; } = null!; + public DbSet PostTags { get; set; } = null!; + public DbSet PostCategories { get; set; } = null!; + public DbSet PostCollections { get; set; } = null!; + public DbSet Realms { get; set; } = null!; + public DbSet RealmMembers { get; set; } = null!; + public DbSet Tags { get; set; } = null!; + public DbSet RealmTags { get; set; } = null!; + public DbSet ChatRooms { get; set; } = null!; + public DbSet ChatMembers { get; set; } = null!; + public DbSet ChatMessages { get; set; } = null!; + public DbSet ChatRealtimeCall { get; set; } = null!; + public DbSet ChatReactions { get; set; } = null!; + public DbSet Stickers { get; set; } = null!; + public DbSet StickerPacks { get; set; } = null!; + public DbSet Wallets { get; set; } = null!; + public DbSet WalletPockets { get; set; } = null!; + public DbSet PaymentOrders { get; set; } = null!; + public DbSet PaymentTransactions { get; set; } = null!; + public DbSet CustomApps { get; set; } = null!; + public DbSet CustomAppSecrets { get; set; } = null!; + public DbSet WalletSubscriptions { get; set; } = null!; + // TODO: Fix Connection type - no Connection class found in DysonNetwork.Sphere.Connection + // public DbSet Connections { get; set; } = null!; + // public DbSet Followers { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Configure the database schema and relationships here + // This will be moved from the original AppDatabase class + } +} diff --git a/DysonNetwork.Sphere/Developer/CustomAppService.cs b/DysonNetwork.Sphere/Developer/CustomAppService.cs index 6455b37..c29d88d 100644 --- a/DysonNetwork.Sphere/Developer/CustomAppService.cs +++ b/DysonNetwork.Sphere/Developer/CustomAppService.cs @@ -1,10 +1,11 @@ +using DysonNetwork.Common.Interfaces; using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Sphere.Developer; -public class CustomAppService(AppDatabase db, FileReferenceService fileRefService) +public class CustomAppService(AppDatabase db, IFileReferenceServiceClient fileRefService) { public async Task CreateAppAsync( Publisher.Publisher pub, @@ -32,7 +33,7 @@ public class CustomAppService(AppDatabase db, FileReferenceService fileRefServic // Create a new reference await fileRefService.CreateReferenceAsync( - picture.Id, + picture.Id.ToString(), "custom-apps.picture", app.ResourceIdentifier ); @@ -101,9 +102,9 @@ public class CustomAppService(AppDatabase db, FileReferenceService fileRefServic // Create a new reference await fileRefService.CreateReferenceAsync( - picture.Id, + picture.Id.ToString(), "custom-apps.picture", - app.ResourceIdentifier + app.ResourceIdentifier ); } diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 2227753..bdaddf0 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -1,4 +1,4 @@ - + @@ -39,10 +39,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + @@ -76,10 +76,10 @@ - - + + - + @@ -89,16 +89,21 @@ - - - - + + + + + + + + + ResXFileCodeGenerator diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index c721842..6a4af7c 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using DysonNetwork.Common.Interfaces; using DysonNetwork.Common.Models; using DysonNetwork.Common.Services; using DysonNetwork.Sphere.Connection.WebReader; @@ -13,7 +14,7 @@ namespace DysonNetwork.Sphere.Post; public partial class PostService( AppDatabase db, - FileReferenceService fileRefService, + IFileReferenceServiceClient fileRefService, IStringLocalizer localizer, IServiceScopeFactory factory, FlushBufferService flushBuffer, @@ -135,9 +136,9 @@ public partial class PostService( foreach (var file in post.Attachments) { await fileRefService.CreateReferenceAsync( - file.Id, + file.Id.ToString(), PostFileUsageIdentifier, - postResourceId + post.ResourceIdentifier ); } } @@ -218,12 +219,18 @@ public partial class PostService( { var postResourceId = $"post:{post.Id}"; - // Update resource references using the new file list - await fileRefService.UpdateResourceFilesAsync( - postResourceId, - attachments, - PostFileUsageIdentifier - ); + // Delete existing references for this resource and usage + await fileRefService.DeleteResourceReferencesAsync(post.ResourceIdentifier, PostFileUsageIdentifier); + + // Create new references for each file + foreach (var fileId in attachments) + { + await fileRefService.CreateReferenceAsync( + fileId.ToString(), + PostFileUsageIdentifier, + post.ResourceIdentifier + ); + } // Update post attachments by getting files from database var files = await db.Files diff --git a/DysonNetwork.Sphere/Publisher/PublisherController.cs b/DysonNetwork.Sphere/Publisher/PublisherController.cs index 0041693..ed939ea 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherController.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using DysonNetwork.Common.Interfaces; using DysonNetwork.Common.Models; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Realm; @@ -15,7 +16,7 @@ namespace DysonNetwork.Sphere.Publisher; public class PublisherController( AppDatabase db, PublisherService ps, - FileReferenceService fileRefService, + IFileReferenceServiceClient fileRefService, ActionLogService als) : ControllerBase { @@ -362,7 +363,7 @@ public class PublisherController( // Create a new reference await fileRefService.CreateReferenceAsync( - picture.Id, + picture.Id.ToString(), "publisher.picture", publisher.ResourceIdentifier ); @@ -384,7 +385,7 @@ public class PublisherController( // Create a new reference await fileRefService.CreateReferenceAsync( - background.Id, + background.Id.ToString(), "publisher.background", publisher.ResourceIdentifier ); diff --git a/DysonNetwork.Sphere/Publisher/PublisherService.cs b/DysonNetwork.Sphere/Publisher/PublisherService.cs index 4fac83e..516eab3 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherService.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherService.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Common.Interfaces; using DysonNetwork.Common.Models; using DysonNetwork.Common.Services; using DysonNetwork.Sphere.Post; @@ -8,7 +9,7 @@ using NodaTime; namespace DysonNetwork.Sphere.Publisher; -public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache) +public class PublisherService(AppDatabase db, IFileReferenceServiceClient fileRefService, ICacheService cache) { public async Task GetPublisherByName(string name) { diff --git a/DysonNetwork.Sphere/Realm/RealmController.cs b/DysonNetwork.Sphere/Realm/RealmController.cs index a16f594..5e9d0ef 100644 --- a/DysonNetwork.Sphere/Realm/RealmController.cs +++ b/DysonNetwork.Sphere/Realm/RealmController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using DysonNetwork.Common.Interfaces; using DysonNetwork.Common.Models; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Mvc; @@ -13,7 +14,7 @@ namespace DysonNetwork.Sphere.Realm; public class RealmController( AppDatabase db, RealmService rs, - FileReferenceService fileRefService, + IFileReferenceServiceClient fileRefService, RelationshipService rels, ActionLogService als, AccountEventService aes @@ -424,7 +425,7 @@ public class RealmController( // Create a new reference await fileRefService.CreateReferenceAsync( - picture.Id, + picture.Id.ToString(), "realm.picture", realm.ResourceIdentifier ); @@ -445,7 +446,7 @@ public class RealmController( // Create a new reference await fileRefService.CreateReferenceAsync( - background.Id, + background.Id.ToString(), "realm.background", realm.ResourceIdentifier ); diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 5fc8ff4..bacd435 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -27,6 +27,11 @@ using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Safety; using DysonNetwork.Sphere.Wallet.PaymentHandlers; using tusdotnet.Stores; +using DysonNetwork.Common.Interfaces; +using DysonNetwork.Drive.Clients; +using DysonNetwork.Sphere.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL; +using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Sphere.Startup; @@ -36,7 +41,13 @@ public static class ServiceCollectionExtensions { services.AddLocalization(options => options.ResourcesPath = "Resources"); - services.AddDbContext(); + services.AddDbContext(options => + options.UseNpgsql( + configuration.GetConnectionString("DefaultConnection"), + o => o.UseNodaTime() + ) + .UseSnakeCaseNamingConvention() + ); services.AddSingleton(_ => { var connection = configuration.GetConnectionString("FastRetrieve")!; @@ -49,6 +60,19 @@ public static class ServiceCollectionExtensions services.AddHttpClient(); services.AddScoped(); + // Register HTTP clients for Drive microservice + services.AddHttpClient(client => + { + var baseUrl = configuration["DriveService:BaseUrl"] ?? throw new InvalidOperationException("DriveService:BaseUrl is not configured"); + client.BaseAddress = new Uri(baseUrl); + }); + + services.AddHttpClient(client => + { + var baseUrl = configuration["DriveService:BaseUrl"] ?? throw new InvalidOperationException("DriveService:BaseUrl is not configured"); + client.BaseAddress = new Uri(baseUrl); + }); + // Register OIDC services @@ -181,7 +205,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/DysonNetwork.Sphere/Sticker/StickerService.cs b/DysonNetwork.Sphere/Sticker/StickerService.cs index 779cb69..34e0cf0 100644 --- a/DysonNetwork.Sphere/Sticker/StickerService.cs +++ b/DysonNetwork.Sphere/Sticker/StickerService.cs @@ -1,10 +1,13 @@ +using DysonNetwork.Common.Interfaces; +using DysonNetwork.Common.Models; using DysonNetwork.Common.Services; using DysonNetwork.Sphere.Storage; using Microsoft.EntityFrameworkCore; +using NodaTime; namespace DysonNetwork.Sphere.Sticker; -public class StickerService(AppDatabase db, FileService fs, FileReferenceService fileRefService, ICacheService cache) +public class StickerService(AppDatabase db, IFileReferenceServiceClient fileRefService, ICacheService cache) { public const string StickerFileUsageIdentifier = "sticker"; @@ -19,9 +22,9 @@ public class StickerService(AppDatabase db, FileService fs, FileReferenceService var stickerResourceId = $"sticker:{sticker.Id}"; await fileRefService.CreateReferenceAsync( - sticker.Image.Id, - StickerFileUsageIdentifier, - stickerResourceId + fileId: sticker.Image.Id.ToString(), + usage: StickerFileUsageIdentifier, + resourceId: stickerResourceId ); return sticker; @@ -34,20 +37,23 @@ public class StickerService(AppDatabase db, FileService fs, FileReferenceService var stickerResourceId = $"sticker:{sticker.Id}"; // Delete old references - var oldRefs = - await fileRefService.GetResourceReferencesAsync(stickerResourceId, StickerFileUsageIdentifier); + var oldRefs = await fileRefService.GetResourceReferencesAsync( + resourceId: stickerResourceId, + usage: StickerFileUsageIdentifier + ); + foreach (var oldRef in oldRefs) { - await fileRefService.DeleteReferenceAsync(oldRef.Id); + await fileRefService.DeleteReferenceAsync(oldRef.Id.ToString()); } sticker.Image = newImage.ToReferenceObject(); // Create new reference await fileRefService.CreateReferenceAsync( - newImage.Id, - StickerFileUsageIdentifier, - stickerResourceId + fileId: newImage.Id.ToString(), + usage: StickerFileUsageIdentifier, + resourceId: stickerResourceId ); } diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index e4d3d54..8fb5c79 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -111,5 +111,8 @@ "KnownProxies": [ "127.0.0.1", "::1" - ] + ], + "DriveService": { + "BaseUrl": "http://localhost:5073" + } }