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"
+ }
}