4 Commits

303 changed files with 7572 additions and 104177 deletions

View File

@ -0,0 +1,154 @@
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.Common.Clients
{
public class FileReferenceServiceClient : IFileReferenceServiceClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<FileReferenceServiceClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public FileReferenceServiceClient(HttpClient httpClient, ILogger<FileReferenceServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions()
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_jsonOptions.PropertyNameCaseInsensitive = true;
}
public async Task<CloudFileReference> CreateReferenceAsync(
string fileId,
string usage,
string resourceId,
Instant? expiredAt = null,
Duration? duration = null)
{
var request = new
{
FileId = fileId,
Usage = usage,
ResourceId = resourceId,
ExpiredAt = expiredAt,
Duration = duration
};
var content = new StringContent(
JsonSerializer.Serialize(request, _jsonOptions),
Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync("api/filereferences", content);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
var reference = await JsonSerializer.DeserializeAsync<CloudFileReference>(stream, _jsonOptions);
return reference;
}
public async Task<CloudFileReference> GetReferenceAsync(string referenceId)
{
var response = await _httpClient.GetAsync($"api/filereferences/{referenceId}");
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
var reference = await JsonSerializer.DeserializeAsync<CloudFileReference>(stream, _jsonOptions);
return reference;
}
public async Task<IEnumerable<CloudFileReference>> GetReferencesForResourceAsync(string resourceId, string? usage = null)
{
var url = $"api/filereferences/resource/{resourceId}";
if (!string.IsNullOrEmpty(usage))
{
url += $"?usage={Uri.EscapeDataString(usage)}";
}
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
var references = await JsonSerializer.DeserializeAsync<IEnumerable<CloudFileReference>>(stream, _jsonOptions);
return references ?? Array.Empty<CloudFileReference>();
}
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/{resourceId}";
if (!string.IsNullOrEmpty(usage))
{
url += $"?usage={Uri.EscapeDataString(usage)}";
}
var response = await _httpClient.DeleteAsync(url);
response.EnsureSuccessStatusCode();
}
public async Task<List<CloudFileReference>> GetFileReferencesAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}");
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
var references = await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(stream, _jsonOptions);
return references ?? new List<CloudFileReference>();
}
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string? usage = null)
{
var url = $"api/filereferences/resource/{resourceId}";
if (!string.IsNullOrEmpty(usage))
{
url += $"?usage={Uri.EscapeDataString(usage)}";
}
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
var references = await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(stream, _jsonOptions);
return references ?? new List<CloudFileReference>();
}
public async Task<bool> HasReferencesAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}/exists");
return response.IsSuccessStatusCode;
}
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.PutAsync($"api/filereferences/{referenceId}/expiration", content);
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
_httpClient?.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,116 @@
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;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
namespace DysonNetwork.Common.Clients
{
public class FileServiceClient : IFileServiceClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<FileServiceClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public FileServiceClient(HttpClient httpClient, ILogger<FileServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_jsonOptions.PropertyNameCaseInsensitive = true;
}
public async Task<CloudFile> GetFileAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/files/{fileId}");
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
var file = await JsonSerializer.DeserializeAsync<CloudFile>(stream, _jsonOptions);
return file;
}
public async Task<Stream> GetFileStreamAsync(string fileId)
{
if (string.IsNullOrEmpty(fileId))
throw new ArgumentNullException(nameof(fileId));
var response = await _httpClient.GetAsync($"api/files/{fileId}/download");
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
if (stream == null)
throw new InvalidOperationException("Failed to read file stream from response.");
return stream;
}
public async Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string? contentType = null)
{
using var content = new MultipartFormDataContent();
var fileContent = new StreamContent(fileStream);
if (!string.IsNullOrEmpty(contentType))
{
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
}
content.Add(fileContent, "file", fileName);
var response = await _httpClient.PostAsync("api/files/upload", content);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
var file = await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions);
return file;
}
public async Task<CloudFile> ProcessImageAsync(Stream imageStream, string fileName, string? contentType = null)
{
using var content = new MultipartFormDataContent();
var fileContent = new StreamContent(imageStream);
if (!string.IsNullOrEmpty(contentType))
{
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
}
content.Add(fileContent, "image", fileName);
var response = await _httpClient.PostAsync("api/files/process-image", content);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
var file = await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions);
return file;
}
public async Task<string> GetFileUrl(string fileId, bool useCdn = false)
{
var url = $"api/files/{fileId}/url";
if (useCdn)
{
url += "?useCdn=true";
}
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadAsStringAsync();
return result.Trim('"');
}
public async Task DeleteFileAsync(string fileId)
{
var response = await _httpClient.DeleteAsync($"api/files/{fileId}");
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
_httpClient?.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="NetTopologySuite" Version="2.5.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup>
</Project>

View File

@ -1,6 +1,6 @@
using NodaTime;
namespace DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Common.Interfaces;
/// <summary>
/// Common interface for cloud file entities that can be used in file operations.

View File

@ -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<CloudFileReference> 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<List<CloudFileReference>> GetFileReferencesAsync(string fileId);
Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string? usage = null);
Task<bool> HasReferencesAsync(string fileId);
Task UpdateReferenceExpirationAsync(string referenceId, Instant? expiredAt);
}
}

View File

@ -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<CloudFile> GetFileAsync(string fileId);
Task<Stream> GetFileStreamAsync(string fileId);
Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string? contentType = null);
Task DeleteFileAsync(string fileId);
Task<CloudFile> ProcessImageAsync(Stream imageStream, string fileName, string? contentType = null);
Task<string> GetFileUrl(string fileId, bool useCdn = false);
}
}

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Common.Interfaces;
public interface IIdentifiedResource
{
public string ResourceIdentifier { get; }
}

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public enum AbuseReportType
{

View File

@ -1,14 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using OtpNet;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public enum AccountStatus
{
PendingActivation,
Active,
Suspended,
Banned,
Deleted
}
[Index(nameof(Name), IsUnique = true)]
public class Account : ModelBase
@ -26,13 +32,54 @@ public class Account : ModelBase
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
[JsonIgnore] public ICollection<AuthSession> Sessions { get; set; } = new List<AuthSession>();
[JsonIgnore] public ICollection<AuthChallenge> Challenges { get; set; } = new List<AuthChallenge>();
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
public AccountStatus Status { get; set; } = AccountStatus.PendingActivation;
[NotMapped]
public string? Email => GetPrimaryEmail();
public string? GetPrimaryEmail()
{
return Contacts
.FirstOrDefault(c => c.Type == AccountContactType.Email && c.IsPrimary)
?.Content;
}
public void SetPrimaryEmail(string email)
{
// Remove primary flag from existing primary email if any
foreach (var contact in Contacts.Where(c => c.Type == AccountContactType.Email && c.IsPrimary))
{
contact.IsPrimary = false;
}
// Find or create the email contact
var emailContact = Contacts.FirstOrDefault(c =>
c.Type == AccountContactType.Email &&
string.Equals(c.Content, email, StringComparison.OrdinalIgnoreCase));
if (emailContact == null)
{
emailContact = new AccountContact
{
Type = AccountContactType.Email,
Content = email,
IsPrimary = true
};
Contacts.Add(emailContact);
}
else
{
emailContact.IsPrimary = true;
}
}
}
public abstract class Leveling
@ -131,11 +178,28 @@ public class AccountAuthFactor : ModelBase
/// </summary>
public int Trustworthy { get; set; } = 1;
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? Description { get; set; }
public bool IsDefault { get; set; }
public bool IsBackup { get; set; }
public Instant? LastUsedAt { get; set; }
public Instant? EnabledAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Instant? DisabledAt { get; set; }
[Column(TypeName = "jsonb")]
public Dictionary<string, object>? Metadata { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
// Navigation property for related AuthSessions
[JsonIgnore]
public virtual ICollection<AuthSession>? Sessions { get; set; }
public AccountAuthFactor HashSecret(int cost = 12)
{
@ -177,20 +241,5 @@ public enum AccountAuthFactorType
EmailCode,
InAppCode,
TimedCode,
PinCode,
PinCode
}
public class AccountConnection : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Provider { get; set; } = null!;
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}

View File

@ -0,0 +1,125 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Common.Models;
/// <summary>
/// Represents a connection between an account and an authentication provider
/// </summary>
public class AccountConnection : ModelBase
{
/// <summary>
/// The account ID this connection is associated with
/// </summary>
public string? AccountId { get; set; }
/// <summary>
/// The authentication provider (e.g., "google", "github")
/// </summary>
[Required]
[MaxLength(50)]
public string Provider { get; set; } = null!;
/// <summary>
/// The unique identifier for the user from the provider
/// </summary>
[Required]
[MaxLength(256)]
public string ProvidedIdentifier { get; set; } = null!;
/// <summary>
/// Alias for ProvidedIdentifier for backward compatibility
/// </summary>
[NotMapped]
public string ProviderId
{
get => ProvidedIdentifier;
set => ProvidedIdentifier = value;
}
/// <summary>
/// Display name for the connection
/// </summary>
[MaxLength(100)]
public string? DisplayName { get; set; }
/// <summary>
/// OAuth access token from the provider
/// </summary>
public string? AccessToken { get; set; }
/// <summary>
/// OAuth refresh token from the provider (if available)
/// </summary>
public string? RefreshToken { get; set; }
/// <summary>
/// When the access token expires (if available)
/// </summary>
public Instant? ExpiresAt { get; set; }
/// <summary>
/// Raw profile data from the provider
/// </summary>
[Column(TypeName = "jsonb")]
public JsonDocument? ProfileData { get; set; }
/// <summary>
/// When the connection was first established
/// </summary>
public Instant ConnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
/// <summary>
/// Additional metadata about the connection
/// </summary>
[Column(TypeName = "jsonb")]
public JsonDocument? Metadata { get; set; }
/// <summary>
/// Gets a value indicating whether the connection is currently active
/// </summary>
[NotMapped]
/// <summary>
/// When the connection was first established
/// </summary>
public Instant CreatedAt { get; set; }
/// <summary>
/// When the connection was last used
/// </summary>
public Instant? LastUsedAt { get; set; }
/// <summary>
/// Navigation property for the associated account
/// </summary>
[ForeignKey(nameof(AccountId))]
[JsonIgnore]
public virtual Account? Account { get; set; }
/// <summary>
/// Updates the connection's tokens and related metadata
/// </summary>
/// <param name="accessToken">The new access token</param>
/// <param name="refreshToken">The new refresh token, if any</param>
/// <param name="expiresAt">When the access token expires, if any</param>
public void UpdateTokens(string? accessToken, string? refreshToken, Instant? expiresAt)
{
AccessToken = accessToken;
if (!string.IsNullOrEmpty(refreshToken))
{
RefreshToken = refreshToken;
}
if (expiresAt.HasValue)
{
ExpiresAt = expiresAt;
}
LastUsedAt = SystemClock.Instance.GetCurrentInstant();
}
}

View File

@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Point = NetTopologySuite.Geometries.Point;
using NetTopologySuite.Geometries;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public abstract class ActionLogType
{

View File

@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
namespace DysonNetwork.Common.Models;
public interface IActivity
{

View File

@ -1,13 +1,12 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Drawing;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Developer;
using NodaTime;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Auth;
namespace DysonNetwork.Common.Models;
public class Session : ModelBase
public class AuthSession : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string? Label { get; set; }
@ -15,9 +14,9 @@ public class Session : ModelBase
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; }
public Challenge Challenge { get; set; } = null!;
public AuthChallenge Challenge { get; set; } = null!;
public Guid? AppId { get; set; }
public CustomApp? App { get; set; }
}
@ -40,7 +39,7 @@ public enum ChallengePlatform
Linux
}
public class Challenge : ModelBase
public class AuthChallenge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? ExpiredAt { get; set; }
@ -49,9 +48,9 @@ public class Challenge : ModelBase
public int FailedAttempts { get; set; }
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = [];
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(256)] public string? DeviceId { get; set; }
@ -59,11 +58,33 @@ public class Challenge : ModelBase
public Point? Location { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Models.Account Account { get; set; } = null!;
public Challenge Normalize()
public AuthChallenge Normalize()
{
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
return this;
}
}
public class AuthTokens
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
public string TokenType { get; set; } = "Bearer";
public string? Scope { get; set; }
public string? IdToken { get; set; }
public static AuthTokens Create(string accessToken, string refreshToken, int expiresIn, string? scope = null, string? idToken = null)
{
return new AuthTokens
{
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresIn = expiresIn,
Scope = scope,
IdToken = idToken
};
}
}

View File

@ -0,0 +1,47 @@
namespace DysonNetwork.Common.Models.Auth;
/// <summary>
/// Represents the different types of authentication factors that can be used for multi-factor authentication.
/// </summary>
public enum AuthFactorType
{
/// <summary>
/// Password-based authentication factor.
/// </summary>
Password = 0,
/// <summary>
/// Time-based One-Time Password (TOTP) authentication factor.
/// </summary>
Totp = 1,
/// <summary>
/// Email-based authentication factor.
/// </summary>
Email = 2,
/// <summary>
/// Phone/SMS-based authentication factor.
/// </summary>
Phone = 3,
/// <summary>
/// Security key (FIDO2/WebAuthn) authentication factor.
/// </summary>
SecurityKey = 4,
/// <summary>
/// Recovery code authentication factor.
/// </summary>
RecoveryCode = 5,
/// <summary>
/// Backup code authentication factor.
/// </summary>
BackupCode = 6,
/// <summary>
/// OpenID Connect (OIDC) authentication factor.
/// </summary>
Oidc = 7
}

View File

@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Common.Models.Auth;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthChallengeType
{
// Authentication challenges
Password = 0,
EmailCode = 1,
PhoneCode = 2,
Totp = 3,
WebAuthn = 4,
RecoveryCode = 5,
// Authorization challenges
Consent = 10,
TwoFactor = 11,
// Account recovery challenges
ResetPassword = 20,
VerifyEmail = 21,
VerifyPhone = 22,
// Security challenges
Reauthentication = 30,
DeviceVerification = 31,
// Custom challenges
Custom = 100
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthChallengePlatform
{
Web = 0,
Ios = 1,
Android = 2,
Desktop = 3,
Api = 4,
Cli = 5,
Sdk = 6,
// Special platforms
System = 100,
Unknown = 999
}

View File

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public class Badge : ModelBase
{
@ -16,7 +16,7 @@ public class Badge : ModelBase
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
[JsonIgnore] public Models.Account Account { get; set; } = null!;
public BadgeReferenceObject ToReference()
{

View File

@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Common.Interfaces;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Common.Models;
public enum ChatRoomType
{
@ -31,7 +31,7 @@ public class ChatRoom : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>();
public Guid? RealmId { get; set; }
public Realm.Realm? Realm { get; set; }
public Common.Models.Realm? Realm { get; set; }
[NotMapped]
[JsonPropertyName("members")]
@ -73,7 +73,7 @@ public class ChatMember : ModelBase
public Guid ChatRoomId { get; set; }
public ChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
@ -105,7 +105,7 @@ public class ChatMemberTransmissionObject : ModelBase
public Guid Id { get; set; }
public Guid ChatRoomId { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }

View File

@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Common.Interfaces;
using NodaTime;
namespace DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Common.Models;
public class RemoteStorageConfig
{
@ -74,7 +74,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[MaxLength(4096)]
public string? StorageUrl { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid AccountId { get; set; }
public CloudFileReferenceObject ToReferenceObject()

View File

@ -1,11 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Common.Interfaces;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
namespace DysonNetwork.Common.Models;
public enum CustomAppStatus
{
@ -33,7 +32,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
public Publisher Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
}

View File

@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public enum StatusAttitude
{
@ -23,7 +23,7 @@ public class Status : ModelBase
public Instant? ClearedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Models.Account Account { get; set; } = null!;
}
public enum CheckInResultLevel
@ -44,7 +44,7 @@ public class CheckInResult : ModelBase
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Models.Account Account { get; set; } = null!;
}
public class FortuneTip

View File

@ -0,0 +1,10 @@
using NodaTime;
namespace DysonNetwork.Common.Models;
public class LastActiveInfo
{
public string SessionId { get; set; } = string.Empty;
public string AccountId { get; set; } = string.Empty;
public Instant SeenAt { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace DysonNetwork.Sphere.Models
{
public class LoginRequest
{
public string Username { get; set; }
public string Password { get; set; }
}
public class LoginResponse
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
}
}

View File

@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public enum MagicSpellType
{
@ -26,5 +26,5 @@ public class MagicSpell : ModelBase
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Guid? AccountId { get; set; }
public Account? Account { get; set; }
public Models.Account? Account { get; set; }
}

View File

@ -1,11 +1,10 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Common.Interfaces;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Common.Models;
public class Message : ModelBase, IIdentifiedResource
{

View File

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Common.Models;
/// <summary>
/// Base class for all entity models in the system.
/// Provides common properties and functionality for tracking entity lifecycle.
/// </summary>
public abstract class ModelBase
{
/// <summary>
/// Gets or sets the unique identifier for the entity.
/// </summary>
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Gets or sets the date and time when the entity was created, in UTC.
/// </summary>
public Instant CreatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
/// <summary>
/// Gets or sets the date and time when the entity was last updated, in UTC.
/// </summary>
public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
/// <summary>
/// Gets or sets the date and time when the entity was soft-deleted, in UTC.
/// Null if the entity has not been deleted.
/// </summary>
public Instant? DeletedAt { get; set; }
}

View File

@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public class Notification : ModelBase
{
@ -18,7 +18,7 @@ public class Notification : ModelBase
public Instant? ViewedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
[JsonIgnore] public Models.Account Account { get; set; } = null!;
}
public enum NotificationPushProvider
@ -37,5 +37,5 @@ public class NotificationPushSubscription : ModelBase
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
[JsonIgnore] public Models.Account Account { get; set; } = null!;
}

View File

@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace DysonNetwork.Common.Models;
/// <summary>
/// Represents user information from an OIDC provider
/// </summary>
public class OidcUserInfo
{
/// <summary>
/// The unique identifier for the user from the OIDC provider
/// </summary>
public string? UserId { get; set; }
/// <summary>
/// The user's email address
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Whether the user's email has been verified by the OIDC provider
/// </summary>
public bool EmailVerified { get; set; }
/// <summary>
/// The user's given name (first name)
/// </summary>
public string? GivenName { get; set; }
/// <summary>
/// The user's family name (last name)
/// </summary>
public string? FamilyName { get; set; }
/// <summary>
/// The user's full name
/// </summary>
public string? Name { get; set; }
/// <summary>
/// The user's preferred username
/// </summary>
public string? PreferredUsername { get; set; }
/// <summary>
/// URL to the user's profile picture
/// </summary>
public string? Picture { get; set; }
/// <summary>
/// The OIDC provider name (e.g., "google", "github")
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// OAuth access token from the provider
/// </summary>
public string? AccessToken { get; set; }
/// <summary>
/// OAuth refresh token from the provider (if available)
/// </summary>
public string? RefreshToken { get; set; }
/// <summary>
/// When the access token expires (if available)
/// </summary>
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Additional claims from the ID token or user info endpoint
/// </summary>
public Dictionary<string, object>? Claims { get; set; }
/// <summary>
/// Converts the user info to a metadata dictionary for storage
/// </summary>
public Dictionary<string, object> ToMetadata()
{
var metadata = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(UserId))
metadata["user_id"] = UserId;
if (!string.IsNullOrWhiteSpace(Email))
metadata["email"] = Email;
metadata["email_verified"] = EmailVerified;
if (!string.IsNullOrWhiteSpace(GivenName))
metadata["given_name"] = GivenName;
if (!string.IsNullOrWhiteSpace(FamilyName))
metadata["family_name"] = FamilyName;
if (!string.IsNullOrWhiteSpace(Name))
metadata["name"] = Name;
if (!string.IsNullOrWhiteSpace(PreferredUsername))
metadata["preferred_username"] = PreferredUsername;
if (!string.IsNullOrWhiteSpace(Picture))
metadata["picture"] = Picture;
if (!string.IsNullOrWhiteSpace(Provider))
metadata["provider"] = Provider;
if (ExpiresAt.HasValue)
metadata["expires_at"] = ExpiresAt.Value;
return metadata;
}
}

View File

@ -1,9 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Sphere.Developer;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet;
namespace DysonNetwork.Common.Models;
public class WalletCurrency
{

View File

@ -5,7 +5,7 @@ using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Permission;
namespace DysonNetwork.Common.Models;
/// The permission node model provides the infrastructure of permission control in Dyson Network.
/// It based on the ABAC permission model.

View File

@ -1,12 +1,12 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Common.Interfaces;
using NodaTime;
using NpgsqlTypes;
namespace DysonNetwork.Sphere.Post;
namespace DysonNetwork.Common.Models;
public enum PostType
{
@ -59,7 +59,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
[JsonIgnore] public NpgsqlTsVector SearchVector { get; set; } = null!;
public Guid PublisherId { get; set; }
public Publisher.Publisher Publisher { get; set; } = null!;
public Publisher Publisher { get; set; } = null!;
public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>();
public ICollection<PostTag> Tags { get; set; } = new List<PostTag>();
@ -71,9 +71,9 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
public string ResourceIdentifier => $"post/{Id}";
public Activity.Activity ToActivity()
public Activity ToActivity()
{
return new Activity.Activity()
return new Activity()
{
CreatedAt = PublishedAt ?? CreatedAt,
UpdatedAt = UpdatedAt,
@ -109,7 +109,7 @@ public class PostCollection : ModelBase
[MaxLength(256)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
public Publisher.Publisher Publisher { get; set; } = null!;
public Publisher Publisher { get; set; } = null!;
public ICollection<Post> Posts { get; set; } = new List<Post>();
}
@ -130,5 +130,5 @@ public class PostReaction : ModelBase
public Guid PostId { get; set; }
[JsonIgnore] public Post Post { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
}

View File

@ -1,12 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Publisher;
namespace DysonNetwork.Common.Models;
public enum PublisherType
{
@ -30,9 +29,9 @@ public class Publisher : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>();
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
[JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
[JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>();
@ -41,9 +40,9 @@ public class Publisher : ModelBase, IIdentifiedResource
public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>();
public Guid? AccountId { get; set; }
public Account.Account? Account { get; set; }
public Account? Account { get; set; }
public Guid? RealmId { get; set; }
[JsonIgnore] public Realm.Realm? Realm { get; set; }
[JsonIgnore] public Realm? Realm { get; set; }
public string ResourceIdentifier => $"publisher/{Id}";
}
@ -61,7 +60,7 @@ public class PublisherMember : ModelBase
public Guid PublisherId { get; set; }
[JsonIgnore] public Publisher Publisher { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
public PublisherMemberRole Role { get; set; } = PublisherMemberRole.Viewer;
public Instant? JoinedAt { get; set; }
@ -81,7 +80,7 @@ public class PublisherSubscription : ModelBase
public Guid PublisherId { get; set; }
[JsonIgnore] public Publisher Publisher { get; set; } = null!;
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Account Account { get; set; } = null!;
public PublisherSubscriptionStatus Status { get; set; } = PublisherSubscriptionStatus.Active;
public int Tier { get; set; } = 0;

View File

@ -1,12 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Realm;
namespace DysonNetwork.Common.Models;
[Index(nameof(Slug), IsUnique = true)]
public class Realm : ModelBase, IIdentifiedResource
@ -25,14 +24,13 @@ public class Realm : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<RealmMember> Members { get; set; } = new List<RealmMember>();
[JsonIgnore] public ICollection<ChatRoom> ChatRooms { get; set; } = new List<ChatRoom>();
[JsonIgnore] public ICollection<RealmTag> RealmTags { get; set; } = new List<RealmTag>();
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
[JsonIgnore] public Account Account { get; set; } = null!;
public string ResourceIdentifier => $"realm/{Id}";
}
@ -49,7 +47,7 @@ public class RealmMember : ModelBase
public Guid RealmId { get; set; }
public Realm Realm { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
public int Role { get; set; } = RealmMemberRole.Normal;
public Instant? JoinedAt { get; set; }

View File

@ -1,12 +1,8 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Chat.Realtime;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
namespace DysonNetwork.Common.Models;
public class RealtimeCall : ModelBase
{

View File

@ -1,6 +1,6 @@
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
public enum RelationshipStatus : short
{
@ -12,9 +12,9 @@ public enum RelationshipStatus : short
public class Relationship : ModelBase
{
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Models.Account Account { get; set; } = null!;
public Guid RelatedId { get; set; }
public Account Related { get; set; } = null!;
public Models.Account Related { get; set; } = null!;
public Instant? ExpiredAt { get; set; }

View File

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet;
namespace DysonNetwork.Common.Models;
public record class SubscriptionTypeData(
string Identifier,
@ -138,7 +138,7 @@ public class Subscription : ModelBase
public Instant? RenewalAt { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public Account Account { get; set; } = null!;
[NotMapped]
public bool IsAvailable

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Common.Models;
/// <summary>
/// The verification info of a resource

View File

@ -1,7 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Wallet;
namespace DysonNetwork.Common.Models;
public class Wallet : ModelBase
{
@ -10,7 +10,7 @@ public class Wallet : ModelBase
public ICollection<WalletPocket> Pockets { get; set; } = new List<WalletPocket>();
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
}
public class WalletPocket : ModelBase

View File

@ -4,7 +4,7 @@ using NodaTime;
using NodaTime.Serialization.JsonNet;
using StackExchange.Redis;
namespace DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Common.Services;
/// <summary>
/// Represents a distributed lock that can be used to synchronize access across multiple processes

View File

@ -0,0 +1,64 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Common.Services;
public class FlushBufferService
{
private readonly Dictionary<Type, object> _buffers = new();
private readonly object _lockObject = new();
private ConcurrentQueue<T> GetOrCreateBuffer<T>()
{
var type = typeof(T);
lock (_lockObject)
{
if (!_buffers.TryGetValue(type, out var buffer))
{
buffer = new ConcurrentQueue<T>();
_buffers[type] = buffer;
}
return (ConcurrentQueue<T>)buffer;
}
}
public void Enqueue<T>(T item)
{
var buffer = GetOrCreateBuffer<T>();
buffer.Enqueue(item);
}
public async Task FlushAsync<T>(IFlushHandler<T> handler)
{
var buffer = GetOrCreateBuffer<T>();
var workingQueue = new List<T>();
while (buffer.TryDequeue(out var item))
{
workingQueue.Add(item);
}
if (workingQueue.Count == 0)
return;
try
{
await handler.FlushAsync(workingQueue);
}
catch (Exception)
{
// If flush fails, re-queue the items
foreach (var item in workingQueue)
buffer.Enqueue(item);
throw;
}
}
public int GetPendingCount<T>()
{
var buffer = GetOrCreateBuffer<T>();
return buffer.Count;
}
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Common.Services;
public interface IFlushHandler<T>
{
Task FlushAsync(IReadOnlyList<T> items);
}

View File

@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
using DysonNetwork.Common.Extensions;
using Microsoft.AspNetCore.Http.Extensions;
namespace DysonNetwork.Common.Services.Permission;
[AttributeUsage(AttributeTargets.Method, Inherited = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware<TDbContext> where TDbContext : DbContext
{
private readonly RequestDelegate _next;
private readonly IServiceProvider _serviceProvider;
public PermissionMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
_next = next;
_serviceProvider = serviceProvider;
}
public async Task InvokeAsync(HttpContext httpContext)
{
using var scope = _serviceProvider.CreateScope();
var permissionService = new PermissionService<TDbContext>(
scope.ServiceProvider.GetRequiredService<TDbContext>(),
scope.ServiceProvider.GetRequiredService<ICacheService>()
);
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata.OfType<RequiredPermissionAttribute>().FirstOrDefault();
if (attr != null)
{
if (httpContext.User.Identity?.IsAuthenticated != true)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
var currentUserId = httpContext.User.GetUserId();
if (currentUserId == Guid.Empty)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
// TODO: Check for superuser from PassClient
// if (currentUser.IsSuperuser)
// {
// await _next(httpContext);
// return;
// }
var actor = $"user:{currentUserId}";
var hasPermission = await permissionService.HasPermissionAsync(actor, attr.Area, attr.Key);
if (!hasPermission)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Forbidden");
return;
}
}
await _next.Invoke(httpContext);
}
}
public static class PermissionServiceExtensions
{
public static IServiceCollection AddPermissionService<TDbContext>(this IServiceCollection services)
where TDbContext : DbContext
{
services.AddScoped<PermissionService<TDbContext>>(sp =>
new PermissionService<TDbContext>(
sp.GetRequiredService<TDbContext>(),
sp.GetRequiredService<ICacheService>()
));
return services;
}
public static IApplicationBuilder UsePermissionMiddleware<TDbContext>(this IApplicationBuilder builder)
where TDbContext : DbContext
{
return builder.UseMiddleware<PermissionMiddleware<TDbContext>>(builder.ApplicationServices);
}
}

View File

@ -0,0 +1,203 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Text.Json;
using DysonNetwork.Common.Models;
using DysonNetwork.Common.Services;
namespace DysonNetwork.Common.Services.Permission;
public class PermissionService<TDbContext> where TDbContext : DbContext
{
private readonly TDbContext _db;
private readonly ICacheService _cache;
private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1);
private const string PermCacheKeyPrefix = "perm:";
private const string PermGroupCacheKeyPrefix = "perm-cg:";
private const string PermissionGroupPrefix = "perm-g:";
public PermissionService(TDbContext db, ICacheService cache)
{
_db = db;
_cache = cache;
}
private static string GetPermissionCacheKey(string actor, string area, string key) =>
PermCacheKeyPrefix + actor + ":" + area + ":" + key;
private static string GetGroupsCacheKey(string actor) =>
PermGroupCacheKeyPrefix + actor;
private static string GetPermissionGroupKey(string actor) =>
PermissionGroupPrefix + actor;
public async Task<bool> HasPermissionAsync(string actor, string area, string key)
{
var value = await GetPermissionAsync<bool>(actor, area, key);
return value;
}
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
{
var cacheKey = GetPermissionCacheKey(actor, area, key);
var (hit, cachedValue) = await _cache.GetAsyncWithStatus<T>(cacheKey);
if (hit)
return cachedValue;
var now = SystemClock.Instance.GetCurrentInstant();
var groupsKey = GetGroupsCacheKey(actor);
var groupsId = await _cache.GetAsync<List<Guid>>(groupsKey);
if (groupsId == null)
{
groupsId = await _db.Set<PermissionGroupMember>()
.Where(n => n.Actor == actor)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.Select(e => e.GroupId)
.ToListAsync();
await _cache.SetWithGroupsAsync(groupsKey, groupsId,
[GetPermissionGroupKey(actor)],
CacheExpiration);
}
var permission = await _db.Set<PermissionNode>()
.Where(n => (n.GroupId == null && n.Actor == actor) ||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
.Where(n => n.Key == key && n.Area == area)
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
.FirstOrDefaultAsync();
var result = permission is not null ? DeserializePermissionValue<T>(permission.Value) : default;
await _cache.SetWithGroupsAsync(cacheKey, result,
[GetPermissionGroupKey(actor)],
CacheExpiration);
return result;
}
public async Task<PermissionNode> AddPermissionNode<T>(
string actor,
string area,
string key,
T value,
Instant? expiredAt = null,
Instant? affectedAt = null
)
{
if (value is null) throw new ArgumentNullException(nameof(value));
var node = new PermissionNode
{
Actor = actor,
Key = key,
Area = area,
Value = SerializePermissionValue(value),
ExpiredAt = expiredAt,
AffectedAt = affectedAt
};
_db.Set<PermissionNode>().Add(node);
await _db.SaveChangesAsync();
// Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key);
return node;
}
public async Task<PermissionNode> AddPermissionNodeToGroup<T>(
PermissionGroup group,
string actor,
string area,
string key,
T value,
Instant? expiredAt = null,
Instant? affectedAt = null
)
{
if (value is null) throw new ArgumentNullException(nameof(value));
var node = new PermissionNode
{
Actor = actor,
Key = key,
Area = area,
Value = SerializePermissionValue(value),
ExpiredAt = expiredAt,
AffectedAt = affectedAt,
Group = group,
GroupId = group.Id
};
_db.Set<PermissionNode>().Add(node);
await _db.SaveChangesAsync();
// Invalidate related caches
await InvalidatePermissionCacheAsync(actor, area, key);
await _cache.RemoveAsync(GetGroupsCacheKey(actor));
await _cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
return node;
}
public async Task RemovePermissionNode(string actor, string area, string key)
{
var node = await _db.Set<PermissionNode>()
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
.FirstOrDefaultAsync();
if (node is not null) _db.Set<PermissionNode>().Remove(node);
await _db.SaveChangesAsync();
// Invalidate cache
await InvalidatePermissionCacheAsync(actor, area, key);
}
public async Task RemovePermissionNodeFromGroup(PermissionGroup group, string actor, string area, string key)
{
var node = await _db.Set<PermissionNode>()
.Where(n => n.GroupId == group.Id)
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
.FirstOrDefaultAsync();
if (node is null) return;
_db.Set<PermissionNode>().Remove(node);
await _db.SaveChangesAsync();
// Invalidate caches
await InvalidatePermissionCacheAsync(actor, area, key);
await _cache.RemoveAsync(GetGroupsCacheKey(actor));
await _cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
}
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key)
{
var cacheKey = GetPermissionCacheKey(actor, area, key);
await _cache.RemoveAsync(cacheKey);
}
private static T? DeserializePermissionValue<T>(JsonDocument json)
{
return JsonSerializer.Deserialize<T>(json.RootElement.GetRawText());
}
private static JsonDocument SerializePermissionValue<T>(T obj)
{
var str = JsonSerializer.Serialize(obj);
return JsonDocument.Parse(str);
}
public static PermissionNode NewPermissionNode<T>(string actor, string area, string key, T value)
{
return new PermissionNode
{
Actor = actor,
Area = area,
Key = key,
Value = SerializePermissionValue(value),
};
}
}

View File

@ -0,0 +1,13 @@
using System;
using Microsoft.AspNetCore.Authorization;
namespace DysonNetwork.Drive.Attributes;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class RequiredPermissionAttribute : AuthorizeAttribute
{
public RequiredPermissionAttribute(string permission) : base(permission)
{
Policy = permission;
}
}

View File

@ -0,0 +1,68 @@
using System.Security.Claims;
using DysonNetwork.Drive.Data;
using DysonNetwork.Drive.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
namespace DysonNetwork.Drive.Auth;
public interface IAuthService
{
Task<string> GenerateJwtToken(Account account);
Task<Account?> GetAuthenticatedAccountAsync(ClaimsPrincipal user);
Task<Account?> GetAuthenticatedAccountAsync(HttpContext context);
}
public class AuthService : IAuthService
{
private readonly IConfiguration _configuration;
private readonly AppDatabase _db;
public AuthService(IConfiguration configuration, AppDatabase db)
{
_configuration = configuration;
_db = db;
}
public Task<string> GenerateJwtToken(Account account)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_configuration["Jwt:Secret"] ?? throw new InvalidOperationException("JWT Secret not configured"));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, account.Id.ToString()),
new Claim(ClaimTypes.Name, account.Username),
new Claim(ClaimTypes.Email, account.Email)
}),
Expires = DateTime.UtcNow.AddDays(7),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return Task.FromResult(tokenHandler.WriteToken(token));
}
public async Task<Account?> GetAuthenticatedAccountAsync(ClaimsPrincipal user)
{
var userIdClaim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
return null;
return await _db.Set<Account>().FindAsync(userId);
}
public async Task<Account?> GetAuthenticatedAccountAsync(HttpContext context)
{
return await GetAuthenticatedAccountAsync(context.User);
}
}

View File

@ -0,0 +1,42 @@
using System;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Drive.Models;
namespace DysonNetwork.Drive.Auth;
public class Session
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public Guid AccountId { get; set; }
public virtual Account? Account { get; set; }
[Required]
[MaxLength(64)]
public string Token { get; set; } = null!;
public string? UserAgent { get; set; }
public string? IpAddress { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset LastActiveAt { get; set; } = DateTimeOffset.UtcNow;
public bool IsActive { get; set; } = true;
// Additional metadata
public string? DeviceInfo { get; set; }
public string? LocationInfo { get; set; }
public void UpdateLastActive()
{
LastActiveAt = DateTimeOffset.UtcNow;
}
public bool IsExpired()
{
return ExpiresAt.HasValue && DateTimeOffset.UtcNow >= ExpiresAt.Value;
}
}

View File

@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using DysonNetwork.Common.Interfaces;
using DysonNetwork.Common.Models;
using Microsoft.Extensions.Logging;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
namespace DysonNetwork.Drive.Clients
{
public class FileReferenceServiceClient : IFileReferenceServiceClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<FileReferenceServiceClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public FileReferenceServiceClient(HttpClient httpClient, ILogger<FileReferenceServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions()
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_jsonOptions.PropertyNameCaseInsensitive = true;
}
public async Task<CloudFileReference> CreateReferenceAsync(
string fileId,
string usage,
string resourceId,
Instant? expiredAt = null,
Duration? duration = null)
{
var request = new
{
FileId = fileId,
Usage = usage,
ResourceId = resourceId,
ExpiredAt = expiredAt,
Duration = duration
};
var content = new StringContent(
JsonSerializer.Serialize(request, _jsonOptions),
Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync("api/filereferences", content);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<CloudFileReference>(responseStream, _jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize reference response");
}
public async Task DeleteReferenceAsync(string referenceId)
{
var response = await _httpClient.DeleteAsync($"api/filereferences/{referenceId}");
response.EnsureSuccessStatusCode();
}
public async Task DeleteResourceReferencesAsync(string resourceId, string? usage = null)
{
var url = $"api/filereferences/resource/{Uri.EscapeDataString(resourceId)}";
if (!string.IsNullOrEmpty(usage))
{
url += $"?usage={Uri.EscapeDataString(usage)}";
}
var response = await _httpClient.DeleteAsync(url);
response.EnsureSuccessStatusCode();
}
public async Task<List<CloudFileReference>> GetFileReferencesAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}");
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(responseStream, _jsonOptions)
?? new List<CloudFileReference>();
}
public async Task<List<CloudFileReference>> GetResourceReferencesAsync(string resourceId, string? usage = null)
{
var url = $"api/filereferences/resource/{Uri.EscapeDataString(resourceId)}";
if (!string.IsNullOrEmpty(usage))
{
url += $"?usage={Uri.EscapeDataString(usage)}";
}
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<List<CloudFileReference>>(responseStream, _jsonOptions)
?? new List<CloudFileReference>();
}
public async Task<bool> HasReferencesAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/filereferences/file/{fileId}/has-references");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<bool>(result, _jsonOptions);
}
public async Task UpdateReferenceExpirationAsync(string referenceId, Instant? expiredAt)
{
var request = new { ExpiredAt = expiredAt };
var content = new StringContent(
JsonSerializer.Serialize(request, _jsonOptions),
Encoding.UTF8,
"application/json");
var response = await _httpClient.PatchAsync($"api/filereferences/{referenceId}", content);
response.EnsureSuccessStatusCode();
}
public void Dispose()
{
_httpClient?.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,99 @@
using System;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using DysonNetwork.Common.Interfaces;
using DysonNetwork.Common.Models;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Drive.Clients
{
public class FileServiceClient : IFileServiceClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<FileServiceClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public FileServiceClient(HttpClient httpClient, ILogger<FileServiceClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
}
public async Task<CloudFile> GetFileAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/files/{fileId}");
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<CloudFile>(stream, _jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize file response");
}
public async Task<Stream> GetFileStreamAsync(string fileId)
{
var response = await _httpClient.GetAsync($"api/files/{fileId}/download");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync();
}
public async Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string? contentType = null)
{
using var content = new MultipartFormDataContent
{
{ new StreamContent(fileStream), "file", fileName }
};
var response = await _httpClient.PostAsync("api/files/upload", content);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize upload response");
}
public async Task DeleteFileAsync(string fileId)
{
var response = await _httpClient.DeleteAsync($"api/files/{fileId}");
response.EnsureSuccessStatusCode();
}
public async Task<CloudFile> ProcessImageAsync(Stream imageStream, string fileName, string? contentType = null)
{
using var content = new MultipartFormDataContent
{
{ new StreamContent(imageStream), "image", fileName }
};
var response = await _httpClient.PostAsync("api/files/process-image", content);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<CloudFile>(responseStream, _jsonOptions)
?? throw new InvalidOperationException("Failed to deserialize image processing response");
}
public async Task<string> GetFileUrl(string fileId, bool useCdn = false)
{
var url = $"api/files/{fileId}/url";
if (useCdn)
{
url += "?useCdn=true";
}
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<string>(result, _jsonOptions) ?? string.Empty;
}
public void Dispose()
{
_httpClient?.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@ -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,

View File

@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using DysonNetwork.Drive.Interfaces;
using DysonNetwork.Drive.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Drive.Controllers
{
[ApiController]
[Route("api/files")]
[Authorize]
public class FileController : ControllerBase
{
private readonly IFileService _fileService;
private readonly ILogger<FileController> _logger;
public FileController(IFileService fileService, ILogger<FileController> logger)
{
_fileService = fileService ?? throw new ArgumentNullException(nameof(fileService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[HttpGet("{fileId}")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFile(Guid fileId, CancellationToken cancellationToken = default)
{
try
{
var file = await _fileService.GetFileAsync(fileId, cancellationToken);
var stream = await _fileService.DownloadFileAsync(fileId, cancellationToken);
return File(stream, file.MimeType, file.OriginalName);
}
catch (FileNotFoundException ex)
{
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
return NotFound(new { message = $"File with ID {fileId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving file: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving the file." });
}
}
[HttpPost("upload")]
[ProducesResponseType(typeof(CloudFile), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> UploadFile(IFormFile file, CancellationToken cancellationToken = default)
{
if (file == null || file.Length == 0)
{
return BadRequest(new { message = "No file uploaded." });
}
try
{
using var stream = file.OpenReadStream();
var uploadedFile = await _fileService.UploadFileAsync(
stream,
file.FileName,
file.ContentType,
null,
cancellationToken);
return CreatedAtAction(
nameof(GetFile),
new { fileId = uploadedFile.Id },
uploadedFile);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading file: {FileName}", file?.FileName);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while uploading the file." });
}
}
[HttpDelete("{fileId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteFile(Guid fileId, CancellationToken cancellationToken = default)
{
try
{
var deleted = await _fileService.DeleteFileAsync(fileId, cancellationToken);
if (!deleted)
{
return NotFound(new { message = $"File with ID {fileId} not found." });
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting the file." });
}
}
[HttpGet("{fileId}/metadata")]
[ProducesResponseType(typeof(CloudFile), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFileMetadata(Guid fileId, CancellationToken cancellationToken = default)
{
try
{
var file = await _fileService.GetFileAsync(fileId, cancellationToken);
return Ok(file);
}
catch (FileNotFoundException ex)
{
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
return NotFound(new { message = $"File with ID {fileId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving file metadata: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving file metadata." });
}
}
[HttpPut("{fileId}/metadata")]
[ProducesResponseType(typeof(CloudFile), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateFileMetadata(
Guid fileId,
[FromBody] Dictionary<string, string> metadata,
CancellationToken cancellationToken = default)
{
if (metadata == null || metadata.Count == 0)
{
return BadRequest(new { message = "No metadata provided." });
}
try
{
var updatedFile = await _fileService.UpdateFileMetadataAsync(fileId, metadata, cancellationToken);
return Ok(updatedFile);
}
catch (FileNotFoundException ex)
{
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
return NotFound(new { message = $"File with ID {fileId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating file metadata: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while updating file metadata." });
}
}
[HttpGet("{fileId}/url")]
[ProducesResponseType(typeof(string), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFileUrl(Guid fileId, [FromQuery] int? expiresInSeconds = null, CancellationToken cancellationToken = default)
{
try
{
TimeSpan? expiry = expiresInSeconds.HasValue
? TimeSpan.FromSeconds(expiresInSeconds.Value)
: null;
var url = await _fileService.GetFileUrlAsync(fileId, expiry, cancellationToken);
return Ok(new { url });
}
catch (FileNotFoundException ex)
{
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
return NotFound(new { message = $"File with ID {fileId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating file URL: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while generating the file URL." });
}
}
[HttpGet("{fileId}/thumbnail")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetFileThumbnail(
Guid fileId,
[FromQuery] int? width = null,
[FromQuery] int? height = null,
[FromQuery] int? expiresInSeconds = null,
CancellationToken cancellationToken = default)
{
try
{
TimeSpan? expiry = expiresInSeconds.HasValue
? TimeSpan.FromSeconds(expiresInSeconds.Value)
: null;
var url = await _fileService.GetFileThumbnailUrlAsync(
fileId,
width,
height,
expiry,
cancellationToken);
return Ok(new { url });
}
catch (FileNotFoundException ex)
{
_logger.LogWarning(ex, "File not found: {FileId}", fileId);
return NotFound(new { message = $"File with ID {fileId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating thumbnail URL: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while generating the thumbnail URL." });
}
}
}
}

View File

@ -0,0 +1,325 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DysonNetwork.Drive.Interfaces;
using DysonNetwork.Drive.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Drive.Controllers
{
[ApiController]
[Route("api/references")]
[Authorize]
public class FileReferenceController : ControllerBase
{
private readonly IFileReferenceService _referenceService;
private readonly ILogger<FileReferenceController> _logger;
public FileReferenceController(
IFileReferenceService referenceService,
ILogger<FileReferenceController> logger)
{
_referenceService = referenceService ?? throw new ArgumentNullException(nameof(referenceService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[HttpPost]
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateReference([FromBody] CreateReferenceRequest request, CancellationToken cancellationToken = default)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
var reference = await _referenceService.CreateReferenceAsync(
request.FileId,
request.ResourceId,
request.ResourceType,
request.ReferenceType,
request.ReferenceId,
request.ReferenceName,
request.ReferenceMimeType,
request.ReferenceSize,
request.ReferenceUrl,
request.ReferenceThumbnailUrl,
request.ReferencePreviewUrl,
request.ReferenceMetadata,
request.Metadata,
cancellationToken);
return CreatedAtAction(
nameof(GetReference),
new { referenceId = reference.Id },
reference);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating file reference for file {FileId}", request.FileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while creating the file reference." });
}
}
[HttpGet("{referenceId}")]
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetReference(Guid referenceId, CancellationToken cancellationToken = default)
{
try
{
var reference = await _referenceService.GetReferenceAsync(referenceId, cancellationToken);
return Ok(reference);
}
catch (KeyNotFoundException ex)
{
_logger.LogWarning(ex, "Reference not found: {ReferenceId}", referenceId);
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving reference: {ReferenceId}", referenceId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving the reference." });
}
}
[HttpGet("file/{fileId}")]
[ProducesResponseType(typeof(IEnumerable<CloudFileReference>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReferencesForFile(Guid fileId, CancellationToken cancellationToken = default)
{
try
{
var references = await _referenceService.GetReferencesForFileAsync(fileId, cancellationToken);
return Ok(references);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving references for file: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving references for the file." });
}
}
[HttpGet("resource/{resourceType}/{resourceId}")]
[ProducesResponseType(typeof(IEnumerable<CloudFileReference>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReferencesForResource(
string resourceType,
string resourceId,
CancellationToken cancellationToken = default)
{
try
{
var references = await _referenceService.GetReferencesForResourceAsync(resourceId, resourceType, cancellationToken);
return Ok(references);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving references for resource: {ResourceType}/{ResourceId}", resourceType, resourceId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving references for the resource." });
}
}
[HttpGet("type/{referenceType}")]
[ProducesResponseType(typeof(IEnumerable<CloudFileReference>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReferencesOfType(string referenceType, CancellationToken cancellationToken = default)
{
try
{
var references = await _referenceService.GetReferencesOfTypeAsync(referenceType, cancellationToken);
return Ok(references);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving references of type: {ReferenceType}", referenceType);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while retrieving references of the specified type." });
}
}
[HttpDelete("{referenceId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteReference(Guid referenceId, CancellationToken cancellationToken = default)
{
try
{
var deleted = await _referenceService.DeleteReferenceAsync(referenceId, cancellationToken);
if (!deleted)
{
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting reference: {ReferenceId}", referenceId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting the reference." });
}
}
[HttpDelete("file/{fileId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> DeleteReferencesForFile(Guid fileId, CancellationToken cancellationToken = default)
{
try
{
var count = await _referenceService.DeleteReferencesForFileAsync(fileId, cancellationToken);
return Ok(new { count });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting references for file: {FileId}", fileId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting references for the file." });
}
}
[HttpDelete("resource/{resourceType}/{resourceId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> DeleteReferencesForResource(
string resourceType,
string resourceId,
CancellationToken cancellationToken = default)
{
try
{
var count = await _referenceService.DeleteReferencesForResourceAsync(resourceId, resourceType, cancellationToken);
return Ok(new { count });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting references for resource: {ResourceType}/{ResourceId}", resourceType, resourceId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while deleting references for the resource." });
}
}
[HttpPut("{referenceId}/metadata")]
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateReferenceMetadata(
Guid referenceId,
[FromBody] Dictionary<string, object> metadata,
CancellationToken cancellationToken = default)
{
if (metadata == null || metadata.Count == 0)
{
return BadRequest(new { message = "No metadata provided." });
}
try
{
var reference = await _referenceService.UpdateReferenceMetadataAsync(referenceId, metadata, cancellationToken);
return Ok(reference);
}
catch (KeyNotFoundException ex)
{
_logger.LogWarning(ex, "Reference not found: {ReferenceId}", referenceId);
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating reference metadata: {ReferenceId}", referenceId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while updating the reference metadata." });
}
}
[HttpPut("{referenceId}/resource")]
[ProducesResponseType(typeof(CloudFileReference), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateReferenceResource(
Guid referenceId,
[FromBody] UpdateReferenceResourceRequest request,
CancellationToken cancellationToken = default)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
try
{
var reference = await _referenceService.UpdateReferenceResourceAsync(
referenceId,
request.NewResourceId,
request.NewResourceType,
cancellationToken);
return Ok(reference);
}
catch (KeyNotFoundException ex)
{
_logger.LogWarning(ex, "Reference not found: {ReferenceId}", referenceId);
return NotFound(new { message = $"Reference with ID {referenceId} not found." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating reference resource: {ReferenceId}", referenceId);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while updating the reference resource." });
}
}
[HttpGet("exists/{fileId}/{resourceType}/{resourceId}")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
public async Task<IActionResult> HasReference(
Guid fileId,
string resourceType,
string resourceId,
[FromQuery] string? referenceType = null,
CancellationToken cancellationToken = default)
{
try
{
var exists = await _referenceService.HasReferenceAsync(
fileId,
resourceId,
resourceType,
referenceType,
cancellationToken);
return Ok(new { exists });
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error checking reference existence - File: {FileId}, Resource: {ResourceType}/{ResourceId}, ReferenceType: {ReferenceType}",
fileId,
resourceType,
resourceId,
referenceType);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = "An error occurred while checking reference existence." });
}
}
}
public class CreateReferenceRequest
{
public Guid FileId { get; set; }
public string ResourceId { get; set; } = null!;
public string ResourceType { get; set; } = null!;
public string ReferenceType { get; set; } = null!;
public string? ReferenceId { get; set; }
public string? ReferenceName { get; set; }
public string? ReferenceMimeType { get; set; }
public long? ReferenceSize { get; set; }
public string? ReferenceUrl { get; set; }
public string? ReferenceThumbnailUrl { get; set; }
public string? ReferencePreviewUrl { get; set; }
public string? ReferenceMetadata { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
}
public class UpdateReferenceResourceRequest
{
public string NewResourceId { get; set; } = null!;
public string NewResourceType { get; set; } = null!;
}
}

View File

@ -0,0 +1,127 @@
using System;
using DysonNetwork.Drive.Extensions;
using DysonNetwork.Drive.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Npgsql.EntityFrameworkCore.PostgreSQL;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
namespace DysonNetwork.Drive.Data;
public class AppDatabase : DbContext, IDisposable
{
private readonly IConfiguration _configuration;
public AppDatabase(DbContextOptions<AppDatabase> options, IConfiguration configuration)
: base(options)
{
_configuration = configuration;
}
public DbSet<CloudFile> Files { get; set; } = null!;
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseNpgsql(
_configuration.GetConnectionString("DefaultConnection"),
o => o.UseNodaTime()
);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply snake_case naming convention for all entities
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
// Replace table names
entity.SetTableName(entity.GetTableName()?.ToSnakeCase());
// Replace column names
foreach (var property in entity.GetProperties())
{
property.SetColumnName(property.Name.ToSnakeCase());
}
// Replace keys
foreach (var key in entity.GetKeys())
{
key.SetName(key.GetName()?.ToSnakeCase());
}
// Replace foreign keys
foreach (var key in entity.GetForeignKeys())
{
key.SetConstraintName(key.GetConstraintName()?.ToSnakeCase());
}
// Replace indexes
foreach (var index in entity.GetIndexes())
{
index.SetDatabaseName(index.GetDatabaseName()?.ToSnakeCase());
}
}
// Configure CloudFile entity
modelBuilder.Entity<CloudFile>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.StoragePath).IsUnique();
entity.HasIndex(e => e.ContentHash);
entity.HasIndex(e => e.UploadedById);
entity.HasIndex(e => e.CreatedAt);
entity.Property(e => e.Id).ValueGeneratedOnAdd();
entity.Property(e => e.Name).IsRequired();
entity.Property(e => e.OriginalName).IsRequired();
entity.Property(e => e.MimeType).IsRequired();
entity.Property(e => e.StoragePath).IsRequired();
// Configure JSONB column for ExtendedMetadata
entity.Property(e => e.ExtendedMetadata)
.HasColumnType("jsonb");
// Configure relationships
entity.HasMany(e => e.References)
.WithOne(e => e.File)
.HasForeignKey(e => e.FileId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure CloudFileReference entity
modelBuilder.Entity<CloudFileReference>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.ResourceId, e.ResourceType, e.ReferenceType });
entity.HasIndex(e => e.ReferenceId);
entity.Property(e => e.Id).ValueGeneratedOnAdd();
entity.Property(e => e.ResourceId).IsRequired();
entity.Property(e => e.ResourceType).IsRequired();
entity.Property(e => e.ReferenceType).IsRequired();
// Configure JSONB column for Metadata
entity.Property(e => e.Metadata)
.HasColumnType("jsonb");
// Configure relationship with CloudFile
entity.HasOne(e => e.File)
.WithMany(e => e.References)
.HasForeignKey(e => e.FileId)
.OnDelete(DeleteBehavior.Cascade);
});
}
public override void Dispose()
{
base.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Minio" Version="6.0.5" />
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="tusdotnet" Version="2.10.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1701;1702;1591</NoWarn>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,43 @@
using System.Text;
using System.Text.RegularExpressions;
namespace DysonNetwork.Drive.Extensions;
public static class StringExtensions
{
private static readonly Regex _matchFirstCap = new(@"(.)([A-Z][a-z])");
private static readonly Regex _matchAllCap = new(@"([a-z0-9])([A-Z])");
public static string ToSnakeCase(this string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Handle the first character
var result = new StringBuilder();
result.Append(char.ToLowerInvariant(input[0]));
// Process the rest of the string
for (int i = 1; i < input.Length; i++)
{
if (char.IsUpper(input[i]))
{
result.Append('_');
result.Append(char.ToLowerInvariant(input[i]));
}
else
{
result.Append(input[i]);
}
}
// Replace any remaining uppercase letters with lowercase
var output = result.ToString().ToLowerInvariant();
// Handle special cases (acronyms)
output = _matchFirstCap.Replace(output, "$1_$2");
output = _matchAllCap.Replace(output, "$1_$2");
return output.ToLowerInvariant();
}
}

View File

@ -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<ActionResult<CloudFile>> GetFileInfo(string id)
public async Task<ActionResult<Models.CloudFile>> 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<ActionResult> 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<ActionResult> MigrateFileReferences()
{
await rms.ScanAndMigrateReferences();

View File

@ -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;
/// <summary>
/// Job responsible for cleaning up expired file references

View File

@ -1,7 +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)
{

View File

@ -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)
{
@ -101,56 +103,7 @@ public class FileReferenceMigrationService(AppDatabase db)
await db.SaveChangesAsync();
}
private async Task ScanProfiles()
{
var profiles = await db.AccountProfiles
.Where(p => p.PictureId != null || p.BackgroundId != null)
.ToListAsync();
foreach (var profile in profiles)
{
if (profile is { PictureId: not null, Picture: null })
{
var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == profile.PictureId);
if (avatarFile != null)
{
// Create a reference for the avatar file
var reference = new CloudFileReference
{
FileId = avatarFile.Id,
File = avatarFile,
Usage = "profile.picture",
ResourceId = profile.Id.ToString()
};
await db.FileReferences.AddAsync(reference);
profile.Picture = avatarFile.ToReferenceObject();
db.AccountProfiles.Update(profile);
}
}
// Also check for the banner if it exists
if (profile is not { BackgroundId: not null, Background: null }) continue;
var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == profile.BackgroundId);
if (bannerFile == null) continue;
{
// Create a reference for the banner file
var reference = new CloudFileReference
{
FileId = bannerFile.Id,
File = bannerFile,
Usage = "profile.background",
ResourceId = profile.Id.ToString()
};
await db.FileReferences.AddAsync(reference);
profile.Background = bannerFile.ToReferenceObject();
db.AccountProfiles.Update(profile);
}
}
await db.SaveChangesAsync();
}
private async Task ScanChatRooms()
{

View File

@ -1,15 +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,
@ -79,7 +85,7 @@ public class FileService(
MimeType = contentType,
Size = fileSize,
Hash = hash,
AccountId = account.Id
AccountId = accountId
};
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == hash);

View File

@ -1,6 +1,6 @@
using System.Collections.Concurrent;
namespace DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Drive;
public interface IFlushHandler<T>
{

View File

@ -1,8 +1,11 @@
using DysonNetwork.Sphere.Account;
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<ActionLog>
{

View File

@ -1,15 +1,13 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
using DysonNetwork.Drive.Auth;
using DysonNetwork.Drive.Models;
using Microsoft.Extensions.DependencyInjection;
using DysonNetwork.Common.Models;
using System;
namespace DysonNetwork.Sphere.Storage.Handlers;
public class LastActiveInfo
{
public Auth.Session Session { get; set; } = null!;
public Account.Account Account { get; set; } = null!;
public Instant SeenAt { get; set; }
}
namespace DysonNetwork.Drive.Handlers;
public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<LastActiveInfo>
{
@ -20,18 +18,18 @@ public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHa
// Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt
var distinctItems = items
.GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id))
.GroupBy(x => (SessionId: x.SessionId, AccountId: x.AccountId))
.Select(g => g.OrderByDescending(x => x.SeenAt).First())
.ToList();
// Build dictionaries so we can match session/account IDs to their new "last seen" timestamps
var sessionIdMap = distinctItems
.GroupBy(x => x.Session.Id)
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
.GroupBy(x => x.SessionId)
.ToDictionary(g => Guid.Parse(g.Key), g => g.Last().SeenAt);
var accountIdMap = distinctItems
.GroupBy(x => x.Account.Id)
.ToDictionary(g => g.Key, g => g.Last().SeenAt);
.GroupBy(x => x.AccountId)
.ToDictionary(g => Guid.Parse(g.Key), g => g.Last().SeenAt);
// Update sessions using native EF Core ExecuteUpdateAsync
foreach (var kvp in sessionIdMap)
@ -51,7 +49,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)
{

View File

@ -1,10 +1,12 @@
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<MessageReadReceipt>
{

View File

@ -1,12 +1,15 @@
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<Post.PostViewInfo>
public class PostViewFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<PostViewInfo>
{
public async Task FlushAsync(IReadOnlyList<Post.PostViewInfo> items)
public async Task FlushAsync(IReadOnlyList<PostViewInfo> items)
{
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using DysonNetwork.Drive.Models;
namespace DysonNetwork.Drive.Interfaces;
public interface IFileReferenceService
{
Task<CloudFileReference> CreateReferenceAsync(
Guid fileId,
string resourceId,
string resourceType,
string referenceType,
string? referenceId = null,
string? referenceName = null,
string? referenceMimeType = null,
long? referenceSize = null,
string? referenceUrl = null,
string? referenceThumbnailUrl = null,
string? referencePreviewUrl = null,
string? referenceMetadata = null,
IDictionary<string, object>? metadata = null,
CancellationToken cancellationToken = default);
Task<CloudFileReference> GetReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default);
Task<IEnumerable<CloudFileReference>> GetReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<IEnumerable<CloudFileReference>> GetReferencesForResourceAsync(string resourceId, string resourceType, CancellationToken cancellationToken = default);
Task<IEnumerable<CloudFileReference>> GetReferencesOfTypeAsync(string referenceType, CancellationToken cancellationToken = default);
Task<bool> DeleteReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default);
Task<int> DeleteReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<int> DeleteReferencesForResourceAsync(string resourceId, string resourceType, CancellationToken cancellationToken = default);
Task<CloudFileReference> UpdateReferenceMetadataAsync(Guid referenceId, IDictionary<string, object> metadata, CancellationToken cancellationToken = default);
Task<bool> ReferenceExistsAsync(Guid referenceId, CancellationToken cancellationToken = default);
Task<bool> HasReferenceAsync(Guid fileId, string resourceId, string resourceType, string? referenceType = null, CancellationToken cancellationToken = default);
Task<CloudFileReference> UpdateReferenceResourceAsync(Guid referenceId, string newResourceId, string newResourceType, CancellationToken cancellationToken = default);
Task<IEnumerable<CloudFile>> GetFilesForResourceAsync(string resourceId, string resourceType, string? referenceType = null, CancellationToken cancellationToken = default);
Task<IEnumerable<CloudFile>> GetFilesForReferenceTypeAsync(string referenceType, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using DysonNetwork.Drive.Models;
namespace DysonNetwork.Drive.Interfaces;
public interface IFileService
{
Task<CloudFile> GetFileAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<Stream> DownloadFileAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string contentType, IDictionary<string, string>? metadata = null, CancellationToken cancellationToken = default);
Task<bool> DeleteFileAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<CloudFile> UpdateFileMetadataAsync(Guid fileId, IDictionary<string, string> metadata, CancellationToken cancellationToken = default);
Task<bool> FileExistsAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<string> GetFileUrlAsync(Guid fileId, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
Task<string> GetFileThumbnailUrlAsync(Guid fileId, int? width = null, int? height = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default);
Task<CloudFile> CopyFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default);
Task<CloudFile> MoveFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default);
Task<CloudFile> RenameFileAsync(Guid fileId, string newName, CancellationToken cancellationToken = default);
Task<long> GetFileSizeAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<string> GetFileHashAsync(Guid fileId, CancellationToken cancellationToken = default);
Task<Stream> GetFileThumbnailAsync(Guid fileId, int? width = null, int? height = null, CancellationToken cancellationToken = default);
Task<CloudFile> SetFileVisibilityAsync(Guid fileId, bool isPublic, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace DysonNetwork.Drive.Models;
public class Account : ModelBase
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(256)]
public string Username { get; set; } = null!;
[Required]
[MaxLength(256)]
public string Email { get; set; } = null!;
[MaxLength(1024)]
public string? DisplayName { get; set; }
public bool IsActive { get; set; } = true;
public bool IsVerified { get; set; } = false;
// Navigation properties
public virtual ICollection<CloudFile> Files { get; set; } = new List<CloudFile>();
// Timestamps
public DateTimeOffset? LastLoginAt { get; set; }
// Methods
public bool HasPermission(Permission permission)
{
// TODO: Implement actual permission checking logic
return true;
}
}

View File

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using NodaTime;
namespace DysonNetwork.Drive.Models;
public class CloudFile : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(256)]
public string Name { get; set; } = null!;
[MaxLength(1024)]
public string OriginalName { get; set; } = null!;
[MaxLength(256)]
public string MimeType { get; set; } = null!;
public long Size { get; set; }
[MaxLength(1024)]
public string StoragePath { get; set; } = null!;
[MaxLength(64)]
public string StorageProvider { get; set; } = "local";
[MaxLength(64)]
public string? ContentHash { get; set; }
[MaxLength(1024)]
public string? ThumbnailPath { get; set; }
[MaxLength(1024)]
public string? PreviewPath { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public float? Duration { get; set; }
[MaxLength(1024)]
public string? Metadata { get; set; }
[Column(TypeName = "jsonb")]
public JsonDocument? ExtendedMetadata { get; set; }
public bool IsPublic { get; set; }
public bool IsTemporary { get; set; }
public bool IsDeleted { get; set; }
public Instant? ExpiresAt { get; set; }
public new Instant? DeletedAt { get; set; }
public Guid? UploadedById { get; set; }
public string? UploadedByType { get; set; }
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
public void Dispose()
{
ExtendedMetadata?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,61 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Drive.Models;
public class CloudFileReference : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(2048)]
public string ResourceId { get; set; } = null!;
[MaxLength(256)]
public string ResourceType { get; set; } = null!;
[MaxLength(256)]
public string ReferenceType { get; set; } = null!;
[MaxLength(256)]
public string? ReferenceId { get; set; }
[MaxLength(256)]
public string? ReferenceName { get; set; }
[MaxLength(256)]
public string? ReferenceMimeType { get; set; }
public long? ReferenceSize { get; set; }
[MaxLength(1024)]
public string? ReferenceUrl { get; set; }
[MaxLength(1024)]
public string? ReferenceThumbnailUrl { get; set; }
[MaxLength(1024)]
public string? ReferencePreviewUrl { get; set; }
[MaxLength(1024)]
public string? ReferenceMetadata { get; set; }
[Column(TypeName = "jsonb")]
public JsonDocument? Metadata { get; set; }
public bool IsActive { get; set; } = true;
public Instant? ExpiresAt { get; set; }
public Guid FileId { get; set; }
public virtual CloudFile File { get; set; } = null!;
public void Dispose()
{
Metadata?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,10 @@
using System;
namespace DysonNetwork.Drive.Models;
public abstract class ModelBase
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? UpdatedAt { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}

View File

@ -0,0 +1,19 @@
namespace DysonNetwork.Drive.Models;
public enum Permission
{
// File permissions
File_Read,
File_Write,
File_Delete,
File_Share,
// Admin permissions
Admin_Access,
Admin_ManageUsers,
Admin_ManageFiles,
// Special permissions
BypassRateLimit,
BypassQuota
}

View File

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Drive.Models;
public class Post : ModelBase
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(1024)]
public string Title { get; set; } = null!;
public string Content { get; set; } = string.Empty;
public Guid AuthorId { get; set; }
public virtual Account? Author { get; set; }
public bool IsPublished { get; set; } = false;
public bool IsDeleted { get; set; } = false;
// Navigation properties
public virtual ICollection<PostViewInfo> Views { get; set; } = new List<PostViewInfo>();
public virtual ICollection<CloudFileReference> Attachments { get; set; } = new List<CloudFileReference>();
}
public class PostViewInfo
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public Guid PostId { get; set; }
public virtual Post? Post { get; set; }
public string? UserAgent { get; set; }
public string? IpAddress { get; set; }
public string? Referrer { get; set; }
public string? ViewerId { get; set; }
public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow;
// Additional metadata
public string? CountryCode { get; set; }
public string? DeviceType { get; set; }
public string? Platform { get; set; }
public string? Browser { get; set; }
}

View File

@ -0,0 +1,193 @@
using System;
using System.IO;
using System.Text.Json.Serialization;
using DysonNetwork.Drive.Data;
using DysonNetwork.Drive.Extensions;
using DysonNetwork.Drive.Interfaces;
using DysonNetwork.Drive.Models;
using DysonNetwork.Drive.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using NodaTime;
using Npgsql;
var builder = WebApplication.CreateBuilder(args);
// Add configuration
var configuration = builder.Configuration;
// Add services to the container.
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
// Add NodaTime
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
// Add database context
builder.Services.AddDbContext<AppDatabase>((serviceProvider, options) =>
{
var connectionString = configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("Database connection string 'DefaultConnection' not found.");
}
options.UseNpgsql(
connectionString,
npgsqlOptions =>
{
npgsqlOptions.UseNodaTime();
npgsqlOptions.MigrationsAssembly(typeof(Program).Assembly.FullName);
});
options.UseSnakeCaseNamingConvention();
});
// Register services
builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IFileReferenceService, FileReferenceService>();
// Configure CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
// Configure JWT Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = configuration["Jwt:Authority"];
options.Audience = configuration["Jwt:Audience"];
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
});
// Configure Swagger
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "DysonNetwork.Drive API",
Version = "v1",
Description = "API for managing files and file references in the Dyson Network",
Contact = new OpenApiContact
{
Name = "Dyson Network Team",
Email = "support@dyson.network"
}
});
// Include XML comments for API documentation
var xmlFile = $"{typeof(Program).Assembly.GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
{
c.IncludeXmlComments(xmlPath);
}
// Configure JWT Bearer Authentication for Swagger
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
Array.Empty<string>()
}
});
});
// Configure HTTP client for external services
builder.Services.AddHttpClient();
// Add health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDatabase>();
// Add logging
builder.Services.AddLogging(configure => configure.AddConsole().AddDebug());
// Build the application
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "DysonNetwork.Drive API v1");
c.RoutePrefix = "swagger";
});
}
// Apply database migrations
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var dbContext = services.GetRequiredService<AppDatabase>();
if (dbContext.Database.IsNpgsql())
{
await dbContext.Database.MigrateAsync();
app.Logger.LogInformation("Database migrations applied successfully.");
}
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred while applying database migrations.");
throw;
}
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.Logger.LogInformation("Starting DysonNetwork.Drive application...");
await app.RunAsync();

View File

@ -0,0 +1,13 @@
{
"profiles": {
"DysonNetwork.Drive": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5073",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DysonNetwork.Drive.Data;
using DysonNetwork.Drive.Interfaces;
using DysonNetwork.Drive.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NodaTime;
namespace DysonNetwork.Drive.Services;
public class FileReferenceService : IFileReferenceService, IDisposable
{
private readonly AppDatabase _dbContext;
private readonly IFileService _fileService;
private readonly IClock _clock;
private readonly ILogger<FileReferenceService> _logger;
private bool _disposed = false;
public FileReferenceService(
AppDatabase dbContext,
IFileService fileService,
IClock clock,
ILogger<FileReferenceService> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_fileService = fileService ?? throw new ArgumentNullException(nameof(fileService));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CloudFileReference> CreateReferenceAsync(
Guid fileId,
string resourceId,
string resourceType,
string referenceType,
string? referenceId = null,
string? referenceName = null,
string? referenceMimeType = null,
long? referenceSize = null,
string? referenceUrl = null,
string? referenceThumbnailUrl = null,
string? referencePreviewUrl = null,
string? referenceMetadata = null,
IDictionary<string, object>? metadata = null,
CancellationToken cancellationToken = default)
{
// Verify file exists
var fileExists = await _fileService.FileExistsAsync(fileId, cancellationToken);
if (!fileExists)
{
throw new FileNotFoundException($"File with ID {fileId} not found.");
}
var reference = new CloudFileReference
{
Id = Guid.NewGuid(),
FileId = fileId,
ResourceId = resourceId,
ResourceType = resourceType,
ReferenceType = referenceType,
ReferenceId = referenceId,
ReferenceName = referenceName,
ReferenceMimeType = referenceMimeType,
ReferenceSize = referenceSize,
ReferenceUrl = referenceUrl,
ReferenceThumbnailUrl = referenceThumbnailUrl,
ReferencePreviewUrl = referencePreviewUrl,
ReferenceMetadata = referenceMetadata,
IsActive = true,
CreatedAt = _clock.GetCurrentInstant().ToDateTimeOffset()
};
if (metadata != null && metadata.Any())
{
var options = new JsonSerializerOptions { WriteIndented = true };
reference.Metadata = JsonDocument.Parse(JsonSerializer.Serialize(metadata, options));
}
_dbContext.FileReferences.Add(reference);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Created reference {ReferenceId} for file {FileId} to resource {ResourceType}/{ResourceId}",
reference.Id, fileId, resourceType, resourceId);
return reference;
}
public async Task<CloudFileReference> GetReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default)
{
var reference = await _dbContext.FileReferences
.AsNoTracking()
.Include(r => r.File)
.FirstOrDefaultAsync(r => r.Id == referenceId, cancellationToken);
if (reference == null)
{
throw new KeyNotFoundException($"Reference with ID {referenceId} not found.");
}
return reference;
}
public async Task<IEnumerable<CloudFileReference>> GetReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default)
{
return await _dbContext.FileReferences
.AsNoTracking()
.Where(r => r.FileId == fileId && r.IsActive)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<CloudFileReference>> GetReferencesForResourceAsync(
string resourceId,
string resourceType,
CancellationToken cancellationToken = default)
{
return await _dbContext.FileReferences
.AsNoTracking()
.Where(r => r.ResourceId == resourceId &&
r.ResourceType == resourceType &&
r.IsActive)
.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<CloudFileReference>> GetReferencesOfTypeAsync(
string referenceType,
CancellationToken cancellationToken = default)
{
return await _dbContext.FileReferences
.AsNoTracking()
.Where(r => r.ReferenceType == referenceType && r.IsActive)
.ToListAsync(cancellationToken);
}
public async Task<bool> DeleteReferenceAsync(Guid referenceId, CancellationToken cancellationToken = default)
{
var reference = await _dbContext.FileReferences
.FirstOrDefaultAsync(r => r.Id == referenceId, cancellationToken);
if (reference == null)
{
return false;
}
reference.IsActive = false;
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Deleted reference {ReferenceId}", referenceId);
return true;
}
public async Task<int> DeleteReferencesForFileAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var references = await _dbContext.FileReferences
.Where(r => r.FileId == fileId && r.IsActive)
.ToListAsync(cancellationToken);
foreach (var reference in references)
{
reference.IsActive = false;
}
var count = await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Deleted {Count} references for file {FileId}", count, fileId);
return count;
}
public async Task<int> DeleteReferencesForResourceAsync(
string resourceId,
string resourceType,
CancellationToken cancellationToken = default)
{
var references = await _dbContext.FileReferences
.Where(r => r.ResourceId == resourceId &&
r.ResourceType == resourceType &&
r.IsActive)
.ToListAsync(cancellationToken);
foreach (var reference in references)
{
reference.IsActive = false;
}
var count = await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Deleted {Count} references for resource {ResourceType}/{ResourceId}",
count, resourceType, resourceId);
return count;
}
public async Task<CloudFileReference> UpdateReferenceMetadataAsync(
Guid referenceId,
IDictionary<string, object> metadata,
CancellationToken cancellationToken = default)
{
var reference = await GetReferenceAsync(referenceId, cancellationToken);
var options = new JsonSerializerOptions { WriteIndented = true };
reference.Metadata = JsonDocument.Parse(JsonSerializer.Serialize(metadata, options));
reference.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Updated metadata for reference {ReferenceId}", referenceId);
return reference;
}
public async Task<bool> ReferenceExistsAsync(Guid referenceId, CancellationToken cancellationToken = default)
{
return await _dbContext.FileReferences
.AsNoTracking()
.AnyAsync(r => r.Id == referenceId && r.IsActive, cancellationToken);
}
public async Task<bool> HasReferenceAsync(
Guid fileId,
string resourceId,
string resourceType,
string? referenceType = null,
CancellationToken cancellationToken = default)
{
var query = _dbContext.FileReferences
.AsNoTracking()
.Where(r => r.FileId == fileId &&
r.ResourceId == resourceId &&
r.ResourceType == resourceType &&
r.IsActive);
if (!string.IsNullOrEmpty(referenceType))
{
query = query.Where(r => r.ReferenceType == referenceType);
}
return await query.AnyAsync(cancellationToken);
}
public async Task<CloudFileReference> UpdateReferenceResourceAsync(
Guid referenceId,
string newResourceId,
string newResourceType,
CancellationToken cancellationToken = default)
{
var reference = await GetReferenceAsync(referenceId, cancellationToken);
reference.ResourceId = newResourceId;
reference.ResourceType = newResourceType;
reference.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Updated reference {ReferenceId} to point to resource {ResourceType}/{ResourceId}",
referenceId, newResourceType, newResourceId);
return reference;
}
public async Task<IEnumerable<CloudFile>> GetFilesForResourceAsync(
string resourceId,
string resourceType,
string? referenceType = null,
CancellationToken cancellationToken = default)
{
var query = _dbContext.FileReferences
.AsNoTracking()
.Include(r => r.File)
.Where(r => r.ResourceId == resourceId &&
r.ResourceType == resourceType &&
r.IsActive);
if (!string.IsNullOrEmpty(referenceType))
{
query = query.Where(r => r.ReferenceType == referenceType);
}
var references = await query.ToListAsync(cancellationToken);
return references.Select(r => r.File!);
}
public async Task<IEnumerable<CloudFile>> GetFilesForReferenceTypeAsync(
string referenceType,
CancellationToken cancellationToken = default)
{
var references = await _dbContext.FileReferences
.AsNoTracking()
.Include(r => r.File)
.Where(r => r.ReferenceType == referenceType && r.IsActive)
.ToListAsync(cancellationToken);
return references.Select(r => r.File!);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_dbContext?.Dispose();
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DysonNetwork.Drive.Data;
using DysonNetwork.Drive.Extensions;
using DysonNetwork.Drive.Interfaces;
using DysonNetwork.Drive.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NodaTime;
namespace DysonNetwork.Drive.Services;
public class FileService : IFileService, IDisposable
{
private readonly ILogger<FileService> _logger;
private readonly AppDatabase _dbContext;
private readonly IClock _clock;
private bool _disposed = false;
public FileService(AppDatabase dbContext, IClock clock, ILogger<FileService> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<CloudFile> GetFileAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var file = await _dbContext.Files
.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == fileId, cancellationToken);
if (file == null)
{
throw new FileNotFoundException($"File with ID {fileId} not found.");
}
return file;
}
public async Task<Stream> DownloadFileAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var file = await GetFileAsync(fileId, cancellationToken);
// In a real implementation, this would stream the file from storage (e.g., S3, local filesystem)
// For now, we'll return a MemoryStream with a placeholder
var placeholder = $"This is a placeholder for file {fileId} with name {file.Name}";
var memoryStream = new MemoryStream();
var writer = new StreamWriter(memoryStream);
await writer.WriteAsync(placeholder);
await writer.FlushAsync();
memoryStream.Position = 0;
return memoryStream;
}
public async Task<CloudFile> UploadFileAsync(
Stream fileStream,
string fileName,
string contentType,
IDictionary<string, string>? metadata = null,
CancellationToken cancellationToken = default)
{
if (fileStream == null) throw new ArgumentNullException(nameof(fileStream));
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentNullException(nameof(fileName));
if (string.IsNullOrWhiteSpace(contentType)) throw new ArgumentNullException(nameof(contentType));
// In a real implementation, this would upload to a storage service
var now = _clock.GetCurrentInstant();
var file = new CloudFile
{
Id = Guid.NewGuid(),
Name = Path.GetFileName(fileName),
OriginalName = fileName,
MimeType = contentType,
Size = fileStream.Length,
StoragePath = $"uploads/{now.ToUnixTimeMilliseconds()}/{Guid.NewGuid()}/{Path.GetFileName(fileName)}",
StorageProvider = "local", // or "s3", "azure", etc.
CreatedAt = now.ToDateTimeOffset(),
IsPublic = false,
IsTemporary = false,
IsDeleted = false
};
if (metadata != null)
{
file.Metadata = System.Text.Json.JsonSerializer.Serialize(metadata);
}
_dbContext.Files.Add(file);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Uploaded file {FileId} with name {FileName}", file.Id, file.Name);
return file;
}
public async Task<bool> DeleteFileAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var file = await _dbContext.Files.FindAsync(new object[] { fileId }, cancellationToken);
if (file == null)
{
return false;
}
// In a real implementation, this would also delete the file from storage
file.IsDeleted = true;
file.DeletedAt = _clock.GetCurrentInstant();
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Soft-deleted file {FileId}", fileId);
return true;
}
public async Task<CloudFile> UpdateFileMetadataAsync(Guid fileId, IDictionary<string, string> metadata, CancellationToken cancellationToken = default)
{
var file = await GetFileAsync(fileId, cancellationToken);
file.Metadata = System.Text.Json.JsonSerializer.Serialize(metadata);
var now = _clock.GetCurrentInstant();
file.UpdatedAt = new DateTimeOffset(now.ToDateTimeUtc(), TimeSpan.Zero);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Updated metadata for file {FileId}", fileId);
return file;
}
public Task<bool> FileExistsAsync(Guid fileId, CancellationToken cancellationToken = default)
{
return _dbContext.Files
.AsNoTracking()
.AnyAsync(f => f.Id == fileId && !f.IsDeleted, cancellationToken);
}
public Task<string> GetFileUrlAsync(Guid fileId, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
{
// In a real implementation, this would generate a signed URL with the specified expiry
return Task.FromResult($"https://storage.dyson.network/files/{fileId}");
}
public Task<string> GetFileThumbnailUrlAsync(Guid fileId, int? width = null, int? height = null, TimeSpan? expiry = null, CancellationToken cancellationToken = default)
{
// In a real implementation, this would generate a signed thumbnail URL
var size = width.HasValue || height.HasValue
? $"_{width ?? 0}x{height ?? 0}"
: string.Empty;
return Task.FromResult($"https://storage.dyson.network/thumbnails/{fileId}{size}");
}
public async Task<CloudFile> CopyFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default)
{
var sourceFile = await GetFileAsync(sourceFileId, cancellationToken);
var newFile = new CloudFile
{
Id = Guid.NewGuid(),
Name = newName ?? sourceFile.Name,
OriginalName = sourceFile.OriginalName,
MimeType = sourceFile.MimeType,
Size = sourceFile.Size,
StoragePath = $"copies/{_clock.GetCurrentInstant().ToUnixTimeMilliseconds()}/{Guid.NewGuid()}/{sourceFile.Name}",
StorageProvider = sourceFile.StorageProvider,
ContentHash = sourceFile.ContentHash,
ThumbnailPath = sourceFile.ThumbnailPath,
PreviewPath = sourceFile.PreviewPath,
Width = sourceFile.Width,
Height = sourceFile.Height,
Duration = sourceFile.Duration,
Metadata = newMetadata != null
? System.Text.Json.JsonSerializer.Serialize(newMetadata)
: sourceFile.Metadata,
IsPublic = sourceFile.IsPublic,
IsTemporary = sourceFile.IsTemporary,
IsDeleted = false,
ExpiresAt = sourceFile.ExpiresAt,
UploadedById = sourceFile.UploadedById,
UploadedByType = sourceFile.UploadedByType,
CreatedAt = _clock.GetCurrentInstant().ToDateTimeOffset(),
UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset()
};
_dbContext.Files.Add(newFile);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Copied file {SourceFileId} to {NewFileId}", sourceFileId, newFile.Id);
return newFile;
}
public async Task<CloudFile> MoveFileAsync(Guid sourceFileId, string? newName = null, IDictionary<string, string>? newMetadata = null, CancellationToken cancellationToken = default)
{
var sourceFile = await GetFileAsync(sourceFileId, cancellationToken);
// In a real implementation, this would move the file in storage
var newPath = $"moved/{_clock.GetCurrentInstant().ToUnixTimeMilliseconds()}/{Guid.NewGuid()}/{newName ?? sourceFile.Name}";
sourceFile.Name = newName ?? sourceFile.Name;
sourceFile.StoragePath = newPath;
sourceFile.UpdatedAt = _clock.GetCurrentInstant();
if (newMetadata != null)
{
sourceFile.Metadata = System.Text.Json.JsonSerializer.Serialize(newMetadata);
}
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Moved file {FileId} to {NewPath}", sourceFileId, newPath);
return sourceFile;
}
public async Task<CloudFile> RenameFileAsync(Guid fileId, string newName, CancellationToken cancellationToken = default)
{
var file = await GetFileAsync(fileId, cancellationToken);
file.Name = newName;
file.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Renamed file {FileId} to {NewName}", fileId, newName);
return file;
}
public async Task<long> GetFileSizeAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var file = await GetFileAsync(fileId, cancellationToken);
return file.Size;
}
public async Task<string> GetFileHashAsync(Guid fileId, CancellationToken cancellationToken = default)
{
var file = await GetFileAsync(fileId, cancellationToken);
if (string.IsNullOrEmpty(file.ContentHash))
{
// In a real implementation, this would compute the hash of the file content
file.ContentHash = Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..16];
await _dbContext.SaveChangesAsync(cancellationToken);
}
return file.ContentHash;
}
public async Task<Stream> GetFileThumbnailAsync(Guid fileId, int? width = null, int? height = null, CancellationToken cancellationToken = default)
{
// In a real implementation, this would generate or retrieve a thumbnail
var placeholder = $"This is a thumbnail for file {fileId} with size {width ?? 0}x{height ?? 0}";
var memoryStream = new MemoryStream();
var writer = new StreamWriter(memoryStream);
await writer.WriteAsync(placeholder);
await writer.FlushAsync();
memoryStream.Position = 0;
return memoryStream;
}
public async Task<CloudFile> SetFileVisibilityAsync(Guid fileId, bool isPublic, CancellationToken cancellationToken = default)
{
var file = await GetFileAsync(fileId, cancellationToken);
if (file.IsPublic != isPublic)
{
file.IsPublic = isPublic;
file.UpdatedAt = _clock.GetCurrentInstant().ToDateTimeOffset();
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Set visibility of file {FileId} to {Visibility}", fileId, isPublic ? "public" : "private");
}
return file;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_dbContext?.Dispose();
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,13 @@
using System.Threading.Tasks;
namespace DysonNetwork.Drive.Services;
public interface ICacheService
{
Task<T?> GetAsync<T>(string key);
Task SetAsync<T>(string key, T value, System.TimeSpan? expiry = null);
Task RemoveAsync(string key);
Task<bool> ExistsAsync(string key);
Task<long> IncrementAsync(string key, long value = 1);
Task<long> DecrementAsync(string key, long value = 1);
}

View File

@ -1,7 +1,7 @@
using System.Globalization;
using System.Text;
namespace DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Drive;
public abstract class TextSanitizer
{

View File

@ -1,14 +1,17 @@
using System.Net;
using System.Text;
using System.Text.Json;
using DysonNetwork.Sphere.Permission;
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
{

View File

@ -0,0 +1,17 @@
namespace DysonNetwork.Pass.Connection;
public class GeoIpService
{
// Dummy class
}
public class WebSocketService
{
// Dummy class
}
public class WebSocketPacket
{
public string Type { get; set; } = null!;
public object Data { get; set; } = null!;
}

View File

@ -0,0 +1,407 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Features.Auth.Models;
// Permission types are now in DysonNetwork.Common.Models
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using NodaTime;
using Quartz;
using Account = DysonNetwork.Common.Models.Account;
using AccountConnection = DysonNetwork.Common.Models.AccountConnection;
using AccountAuthFactor = DysonNetwork.Common.Models.AccountAuthFactor;
using AuthSession = DysonNetwork.Pass.Features.Auth.Models.AuthSession;
using AuthChallenge = DysonNetwork.Pass.Features.Auth.Models.AuthChallenge;
namespace DysonNetwork.Pass.Data;
public class PassDatabase(
DbContextOptions<PassDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account> Accounts { get; set; } = null!;
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<AuthSession> AuthSessions { get; set; }
public DbSet<AuthChallenge> AuthChallenges { 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();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
// Add any initial seeding logic here if needed for PassDatabase
});
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
// Configure AuthSession
modelBuilder.Entity<AuthSession>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.Label)
.HasMaxLength(500);
entity.Property(e => e.LastGrantedAt)
.IsRequired();
entity.Property(e => e.ExpiredAt)
.IsRequired();
entity.Property(e => e.AccessToken)
.HasMaxLength(1000);
entity.Property(e => e.RefreshToken)
.HasMaxLength(1000);
entity.Property(e => e.IpAddress)
.HasMaxLength(128);
entity.Property(e => e.UserAgent)
.HasMaxLength(500);
entity.HasOne(s => s.Account)
.WithMany(a => a.Sessions)
.HasForeignKey(s => s.AccountId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(s => s.Challenge)
.WithMany()
.HasForeignKey(s => s.ChallengeId)
.OnDelete(DeleteBehavior.SetNull);
entity.Property(e => e.Metadata)
.HasColumnType("jsonb");
});
// Configure AuthChallenge
modelBuilder.Entity<AuthChallenge>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Type)
.IsRequired()
.HasConversion<string>();
entity.Property(e => e.Platform)
.IsRequired()
.HasConversion<string>();
entity.Property(e => e.CreatedAt)
.IsRequired();
entity.Property(e => e.ExpiredAt);
entity.Property(e => e.StepRemain)
.IsRequired()
.HasDefaultValue(1);
entity.Property(e => e.StepTotal)
.IsRequired()
.HasDefaultValue(1);
entity.Property(e => e.FailedAttempts)
.IsRequired()
.HasDefaultValue(0);
entity.Property(e => e.IpAddress)
.HasMaxLength(128);
entity.Property(e => e.UserAgent)
.HasMaxLength(512);
entity.Property(e => e.DeviceId)
.HasMaxLength(256);
entity.Property(e => e.Nonce)
.HasMaxLength(1024);
entity.Property(e => e.BlacklistFactors)
.HasColumnType("jsonb");
entity.Property(e => e.Audiences)
.HasColumnType("jsonb");
entity.Property(e => e.Scopes)
.HasColumnType("jsonb");
entity.HasOne<Account>()
.WithMany(a => a.Challenges)
.HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Cascade);
entity.Ignore(e => e.Location); // Ignore Point type as it's not directly supported by EF Core
});
// Configure AccountAuthFactor
modelBuilder.Entity<AccountAuthFactor>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.FactorType)
.IsRequired()
.HasConversion<string>();
entity.Property(e => e.Name)
.IsRequired()
.HasMaxLength(100);
entity.Property(e => e.Description)
.HasMaxLength(500);
entity.Property(e => e.Secret)
.IsRequired()
.HasMaxLength(1024);
entity.Property(e => e.IsDefault)
.IsRequired()
.HasDefaultValue(false);
entity.Property(e => e.IsBackup)
.IsRequired()
.HasDefaultValue(false);
entity.Property(e => e.LastUsedAt);
entity.Property(e => e.EnabledAt);
entity.Property(e => e.DisabledAt);
entity.Property(e => e.Metadata)
.HasColumnType("jsonb");
entity.HasOne(f => f.Account)
.WithMany(a => a.AuthFactors)
.HasForeignKey(f => f.AccountId)
.OnDelete(DeleteBehavior.Cascade);
// Remove the incorrect relationship configuration
// The relationship is already defined in the AuthSession configuration
});
// Configure Account
modelBuilder.Entity<Account>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Email)
.IsRequired()
.HasMaxLength(256);
entity.Property(e => e.Name)
.IsRequired()
.HasMaxLength(256);
entity.Property(e => e.Status)
.HasMaxLength(32);
entity.Property(e => e.CreatedAt)
.IsRequired();
entity.Property(e => e.UpdatedAt)
.IsRequired();
});
// Configure AccountConnection
modelBuilder.Entity<AccountConnection>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Provider)
.IsRequired()
.HasMaxLength(50);
entity.Property(e => e.ProviderId)
.IsRequired()
.HasMaxLength(256);
entity.Property(e => e.DisplayName)
.HasMaxLength(256);
entity.Property(e => e.AccessToken)
.HasMaxLength(1000);
entity.Property(e => e.RefreshToken)
.HasMaxLength(1000);
entity.Property(e => e.ExpiresAt);
entity.Property(e => e.ProfileData)
.HasColumnType("jsonb");
entity.HasOne<Account>()
.WithMany(a => a.Connections)
.HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Cascade);
});
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(PassDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : ModelBase
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
public class PassDatabaseFactory : IDesignTimeDbContextFactory<PassDatabase>
{
public PassDatabase CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var optionsBuilder = new DbContextOptionsBuilder<PassDatabase>();
return new PassDatabase(optionsBuilder.Options, configuration);
}
}
public class PassDatabaseRecyclingJob(PassDatabase db, ILogger<PassDatabaseRecyclingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
logger.LogInformation("Deleting soft-deleted records...");
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
.Select(t => t.ClrType);
foreach (var entityType in entityTypes)
{
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(entityType).Invoke(db, null)!;
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
var finalCondition = Expression.AndAlso(notNull, condition);
var lambda = Expression.Lambda(finalCondition, parameter);
var queryable = set.Provider.CreateQuery(
Expression.Call(
typeof(Queryable),
"Where",
[entityType],
set.Expression,
Expression.Quote(lambda)
)
);
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
.MakeGenericMethod(entityType);
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
db.RemoveRange(items);
}
await db.SaveChangesAsync();
}
}

View File

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace DysonNetwork.Pass.Developer;
public class CustomApp
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(256)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string Description { get; set; } = null!;
[MaxLength(1024)] public string Homepage { get; set; } = null!;
[MaxLength(1024)] public string CallbackUrl { get; set; } = null!;
[Column(TypeName = "jsonb")] public OauthConfig? OauthConfig { get; set; }
public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
}
public class CustomAppSecret
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Secret { get; set; } = null!;
public bool IsOidc { get; set; } = false;
public DateTime? ExpiredAt { get; set; }
public Guid AppId { get; set; }
[JsonIgnore] public CustomApp App { get; set; } = null!;
}
public class OauthConfig
{
public List<string>? AllowedScopes { get; set; }
}

View File

@ -0,0 +1,49 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Features\Account\Controllers\" />
<Folder Include="Features\Account\Services\" />
<Folder Include="Features\Auth\Controllers\" />
<Folder Include="Features\Auth\Models\" />
<Folder Include="Features\Auth\Services\" />
<Folder Include="Data\" />
<Folder Include="Email\" />
<Folder Include="Developer\" />
<Folder Include="Localization\" />
<Folder Include="Storage\" />
<Folder Include="Storage\Handlers\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Common\DysonNetwork.Common.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@DysonNetwork.Pass_HostAddress = http://localhost:5048
GET {{DysonNetwork.Pass_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Pass.Email;
public class EmailModels
{
// Dummy class for EmailModels
}

View File

@ -0,0 +1,10 @@
namespace DysonNetwork.Pass.Email;
public class EmailService
{
public Task SendTemplatedEmailAsync<TTemplate, TModel>(string recipientName, string recipientEmail, string subject, TModel model) where TTemplate : class where TModel : class
{
// Dummy implementation
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,6 @@
namespace DysonNetwork.Pass.Email;
public class RazorViewRenderer
{
// Dummy class for RazorViewRenderer
}

View File

@ -0,0 +1,45 @@
using System.Collections;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Query;
namespace DysonNetwork.Pass.Extensions;
public static class OptionalQueryExtensions
{
public static IQueryable<T> If<
T
>(
this IQueryable<T> source,
bool condition,
Func<IQueryable<T>, IQueryable<T>> transform
)
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<
T,
TP
>(
this IIncludableQueryable<T, TP> source,
bool condition,
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
public static IQueryable<T> If<
T,
TP
>(
this IIncludableQueryable<T, IEnumerable<TP>> source,
bool condition,
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
)
where T : class
{
return condition ? transform(source) : source;
}
}

View File

@ -1,28 +1,27 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Auth;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Features.Account.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Extensions;
using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Features.Account.Controllers;
[ApiController]
[Route("/accounts")]
public class AccountController(
AppDatabase db,
PassDatabase db,
AuthService auth,
AccountService accounts,
AccountEventService events
) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<Common.Models.Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account?>> GetByName(string name)
public async Task<ActionResult<Common.Models.Account?>> GetByName(string name)
{
var account = await db.Accounts
.Include(e => e.Badges)
@ -73,9 +72,9 @@ public class AccountController(
}
[HttpPost]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<Common.Models.Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
public async Task<ActionResult<Common.Models.Account>> CreateAccount([FromBody] AccountCreateRequest request)
{
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
@ -163,7 +162,7 @@ public class AccountController(
}
[HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
public async Task<List<Common.Models.Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query))
return [];

View File

@ -1,20 +1,20 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Features.Account.Services;
using DysonNetwork.Pass.Features.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Org.BouncyCastle.Utilities;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Features.Account.Controllers;
[Authorize]
[ApiController]
[Route("/accounts/me")]
public class AccountCurrentController(
AppDatabase db,
PassDatabase db,
AccountService accounts,
FileReferenceService fileRefService,
AccountEventService events,
@ -22,10 +22,10 @@ public class AccountCurrentController(
) : ControllerBase
{
[HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetCurrentIdentity()
[ProducesResponseType<Common.Models.Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Common.Models.Account>> GetCurrentIdentity()
{
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 account = await db.Accounts
@ -46,7 +46,7 @@ public class AccountCurrentController(
[HttpPatch]
public async Task<ActionResult<Account>> 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<ActionResult<Profile>> 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<ActionResult> 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<ActionResult<Status>> 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<ActionResult<Status>> 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.Common.Services.Permission.RequiredPermission("global", "accounts.statuses.create")]
public async Task<ActionResult<Status>> 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<ActionResult> 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<ActionResult<CheckInResult>> 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<ActionResult<CheckInResult>> 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<ActionResult<List<DailyEventResponse>>> 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<ActionResult<List<AccountAuthFactor>>> 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<ActionResult<AccountAuthFactor>> 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<ActionResult<AccountAuthFactor>> 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<ActionResult<AccountAuthFactor>> 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<ActionResult<AccountAuthFactor>> 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<ActionResult<List<AuthorizedDevice>>> 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<ActionResult<List<Session>>> GetSessions(
public async Task<ActionResult<List<AuthSession>>> 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<ActionResult<Session>> 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<ActionResult<Session>> 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<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
public async Task<ActionResult<AuthSession>> 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<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
public async Task<ActionResult<AuthSession>> 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<ActionResult<List<AccountContact>>> 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<ActionResult<AccountContact>> 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<ActionResult<AccountContact>> 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<ActionResult<AccountContact>> 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<ActionResult<AccountContact>> 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<ActionResult<List<Badge>>> 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<ActionResult<Badge>> 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
{

View File

@ -1,10 +1,12 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Account;
using DysonNetwork.Pass.Data;
namespace DysonNetwork.Pass.Features.Account;
[ApiController]
[Route("/spells")]
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
public class MagicSpellController(PassDatabase db, MagicSpellService sp) : ControllerBase
{
[HttpPost("{spellId:guid}/resend")]
public async Task<ActionResult> ResendMagicSpell(Guid spellId)

View File

@ -1,23 +1,23 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Pass.Features.Auth;
using DysonNetwork.Common.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Features.Account;
[ApiController]
[Route("/notifications")]
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
public class NotificationController(PassDatabase db, NotificationService nty) : ControllerBase
{
[HttpGet("count")]
[Authorize]
public async Task<ActionResult<int>> CountUnreadNotifications()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
if (currentUserValue is not Common.Models.Account currentUser) return Unauthorized();
var count = await db.Notifications
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null)
@ -35,7 +35,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
if (currentUserValue is not Common.Models.Account currentUser) return Unauthorized();
var totalCount = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
@ -67,7 +67,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
var currentUser = currentUserValue as Common.Models.Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
@ -85,7 +85,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
var currentUser = currentUserValue as Common.Models.Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
@ -140,7 +140,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[HttpPost("send")]
[Authorize]
[RequiredPermission("global", "notifications.send")]
[DysonNetwork.Common.Services.Permission.RequiredPermission("global", "notifications.send")]
public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false

View File

@ -1,21 +1,23 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Data;
using DysonNetwork.Common.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Features.Account;
[ApiController]
[Route("/relationships")]
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
public class RelationshipController(PassDatabase db, RelationshipService rels) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
[FromQuery] int take = 20)
{
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 query = db.AccountRelationships.AsQueryable()
@ -46,7 +48,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListSentRequests()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relationships = await db.AccountRelationships
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
@ -69,7 +71,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
@ -92,7 +94,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
try
{
@ -113,7 +115,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable()
@ -133,7 +135,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
@ -158,7 +160,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult> DeleteFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
try
{
@ -175,7 +177,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
@ -195,7 +197,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
@ -215,7 +217,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
@ -235,7 +237,7 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
[Authorize]
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Common.Models.Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");

View File

@ -0,0 +1,20 @@
using System.Globalization;
using DysonNetwork.Common.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface IAccountEventService
{
void PurgeStatusCache(Guid userId);
Task<Status> GetStatus(Guid userId);
Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds);
Task<Status> CreateStatus(Models.Account user, Status status);
Task ClearStatus(Models.Account user, Status status);
Task<bool> CheckInDailyDoAskCaptcha(Models.Account user);
Task<bool> CheckInDailyIsAvailable(Models.Account user);
Task<CheckInResult> CheckInDaily(Models.Account user);
Task<List<DailyEventResponse>> GetEventCalendar(Models.Account user, int month, int year = 0, bool replaceInvisible = false);
}

View File

@ -0,0 +1,46 @@
using System.Globalization;
using NodaTime;
using DysonNetwork.Pass.Features.Auth;
using DysonNetwork.Pass.Features.Auth.OpenId;
using DysonNetwork.Common.Models;
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface IAccountService
{
static void SetCultureInfo(Models.Account account) { }
static void SetCultureInfo(string? languageCode) { }
Task PurgeAccountCache(Models.Account account);
Task<Models.Account?> LookupAccount(string probe);
Task<Models.Account?> LookupAccountByConnection(string identifier, string provider);
Task<int?> GetAccountLevel(Guid accountId);
Task<Models.Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
);
Task<Models.Account> CreateAccount(OidcUserInfo userInfo);
Task RequestAccountDeletion(Models.Account account);
Task RequestPasswordReset(Models.Account account);
Task<bool> CheckAuthFactorExists(Models.Account account, AccountAuthFactorType type);
Task<AccountAuthFactor?> CreateAuthFactor(Models.Account account, AccountAuthFactorType type, string? secret);
Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code);
Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor);
Task DeleteAuthFactor(AccountAuthFactor factor);
Task SendFactorCode(Models.Account account, AccountAuthFactor factor, string? hint = null);
Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code);
Task<Session> UpdateSessionLabel(Models.Account account, Guid sessionId, string label);
Task DeleteSession(Models.Account account, Guid sessionId);
Task<AccountContact> CreateContactMethod(Models.Account account, AccountContactType type, string content);
Task VerifyContactMethod(Models.Account account, AccountContact contact);
Task<AccountContact> SetContactMethodPrimary(Models.Account account, AccountContact contact);
Task DeleteContactMethod(Models.Account account, AccountContact contact);
Task<Badge> GrantBadge(Models.Account account, Badge badge);
Task RevokeBadge(Models.Account account, Guid badgeId);
Task ActiveBadge(Models.Account account, Guid badgeId);
Task EnsureAccountProfileCreated();
}

View File

@ -0,0 +1,9 @@
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface IAccountUsernameService
{
Task<string> GenerateUniqueUsernameAsync(string baseName);
string SanitizeUsername(string username);
Task<bool> IsUsernameExistsAsync(string username);
Task<string> GenerateUsernameFromEmailAsync(string email);
}

View File

@ -0,0 +1,9 @@
using DysonNetwork.Pass.Connection;
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface IActionLogService
{
void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta);
void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, Models.Account? account = null);
}

View File

@ -0,0 +1,20 @@
using NodaTime;
using DysonNetwork.Pass.Features.Account;
using DysonNetwork.Common.Models;
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface IMagicSpellService
{
Task<MagicSpell> CreateMagicSpell(
Models.Account account,
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null,
bool preventRepeat = false
);
Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false);
Task ApplyMagicSpell(MagicSpell spell);
Task ApplyPasswordReset(MagicSpell spell, string newPassword);
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
using DysonNetwork.Common.Models;
using NodaTime;
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface INotificationService
{
Task UnsubscribePushNotifications(string deviceId);
Task<NotificationPushSubscription> SubscribePushNotification(
Models.Account account,
NotificationPushProvider provider,
string deviceId,
string deviceToken
);
Task<Notification> SendNotification(
Models.Account account,
string topic,
string? title = null,
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
string? actionUri = null,
bool isSilent = false,
bool save = true
);
Task DeliveryNotification(Notification notification);
Task MarkNotificationsViewed(ICollection<Notification> notifications);
Task BroadcastNotification(Notification notification, bool save = false);
Task SendNotificationBatch(Notification notification, List<Models.Account> accounts, bool save = false);
}

View File

@ -0,0 +1,28 @@
using DysonNetwork.Common.Models;
using NodaTime;
namespace DysonNetwork.Pass.Features.Account.Interfaces;
public interface IRelationshipService
{
Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId);
Task<Relationship?> GetRelationship(
Guid accountId,
Guid relatedId,
RelationshipStatus? status = null,
bool ignoreExpired = false
);
Task<Relationship> CreateRelationship(Models.Account sender, Models.Account target, RelationshipStatus status);
Task<Relationship> BlockAccount(Models.Account sender, Models.Account target);
Task<Relationship> UnblockAccount(Models.Account sender, Models.Account target);
Task<Relationship> SendFriendRequest(Models.Account sender, Models.Account target);
Task DeleteFriendRequest(Guid accountId, Guid relatedId);
Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
);
Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status);
Task<List<Guid>> ListAccountFriends(Models.Account account);
Task<List<Guid>> ListAccountBlocked(Models.Account account);
Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId, RelationshipStatus status = RelationshipStatus.Friends);
}

View File

@ -1,24 +1,30 @@
using System.Globalization;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using DysonNetwork.Common.Models;
using DysonNetwork.Pass.Data;
using DysonNetwork.Pass.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Asn1.X509;
namespace DysonNetwork.Sphere.Account;
namespace DysonNetwork.Pass.Features.Account.Services;
public class AccountEventService(
AppDatabase db,
WebSocketService ws,
ICacheService cache,
PaymentService payment,
IStringLocalizer<Localization.AccountEventResource> localizer
)
public class AccountEventService
{
private readonly PassDatabase db;
private readonly ICacheService cache;
private readonly IStringLocalizer<Localization.AccountEventResource> localizer;
public AccountEventService(
PassDatabase db,
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer
)
{
this.db = db;
this.cache = cache;
this.localizer = localizer;
}
private static readonly Random Random = new();
private const string StatusCacheKey = "AccountStatus_";
@ -139,7 +145,7 @@ public class AccountEventService(
return results;
}
public async Task<Status> CreateStatus(Account user, Status status)
public async Task<Status> CreateStatus(Common.Models.Account user, Status status)
{
var now = SystemClock.Instance.GetCurrentInstant();
await db.AccountStatuses
@ -152,7 +158,7 @@ public class AccountEventService(
return status;
}
public async Task ClearStatus(Account user, Status status)
public async Task ClearStatus(Common.Models.Account user, Status status)
{
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(status);
@ -164,7 +170,7 @@ public class AccountEventService(
private const string CaptchaCacheKey = "CheckInCaptcha_";
private const int CaptchaProbabilityPercent = 20;
public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
public async Task<bool> CheckInDailyDoAskCaptcha(Common.Models.Account user)
{
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
@ -176,7 +182,7 @@ public class AccountEventService(
return result;
}
public async Task<bool> CheckInDailyIsAvailable(Account user)
public async Task<bool> CheckInDailyIsAvailable(Common.Models.Account user)
{
var now = SystemClock.Instance.GetCurrentInstant();
var lastCheckIn = await db.AccountCheckInResults
@ -195,7 +201,7 @@ public class AccountEventService(
public const string CheckInLockKey = "CheckInLock_";
public async Task<CheckInResult> CheckInDaily(Account user)
public async Task<CheckInResult> CheckInDaily(Common.Models.Account user)
{
var lockKey = $"{CheckInLockKey}{user.Id}";
@ -280,7 +286,7 @@ public class AccountEventService(
return result;
}
public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0,
public async Task<List<DailyEventResponse>> GetEventCalendar(Common.Models.Account user, int month, int year = 0,
bool replaceInvisible = false)
{
if (year == 0)

Some files were not shown because too many files have changed in this diff Show More