:drunk: AI trying to fix bugs

This commit is contained in:
2025-07-06 21:15:30 +08:00
parent 3391c08c04
commit 7d1f096e87
70 changed files with 681 additions and 66945 deletions

View File

@ -89,6 +89,62 @@ namespace DysonNetwork.Common.Clients
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();

View File

@ -35,24 +35,30 @@ namespace DysonNetwork.Common.Clients
return file;
}
public async Task<Stream> DownloadFileAsync(string fileId)
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();
return await response.Content.ReadAsStreamAsync();
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, string? folderId = null)
public async Task<CloudFile> UploadFileAsync(Stream fileStream, string fileName, string? contentType = null)
{
using var content = new MultipartFormDataContent();
var fileContent = new StreamContent(fileStream);
content.Add(fileContent, "file", fileName);
if (!string.IsNullOrEmpty(folderId))
if (!string.IsNullOrEmpty(contentType))
{
content.Add(new StringContent(folderId), "folderId");
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();
@ -61,6 +67,39 @@ namespace DysonNetwork.Common.Clients
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)
{

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@ -9,7 +9,13 @@
<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" />

View File

@ -7,6 +7,15 @@ using OtpNet;
namespace DysonNetwork.Common.Models;
public enum AccountStatus
{
PendingActivation,
Active,
Suspended,
Banned,
Deleted
}
[Index(nameof(Name), IsUnique = true)]
public class Account : ModelBase
{
@ -30,6 +39,47 @@ public class Account : ModelBase
[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
@ -128,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)
{
@ -174,20 +241,5 @@ public enum AccountAuthFactorType
EmailCode,
InAppCode,
TimedCode,
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!;
PinCode
}

View File

@ -1,6 +1,8 @@
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;
@ -8,15 +10,8 @@ namespace DysonNetwork.Common.Models;
/// <summary>
/// Represents a connection between an account and an authentication provider
/// </summary>
public class AccountConnection
public class AccountConnection : ModelBase
{
/// <summary>
/// Unique identifier for the connection
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public string Id { get; set; } = null!;
/// <summary>
/// The account ID this connection is associated with
/// </summary>
@ -36,6 +31,16 @@ public class AccountConnection
[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>
@ -57,6 +62,27 @@ public class AccountConnection
/// </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>
@ -67,15 +93,33 @@ public class AccountConnection
/// </summary>
public Instant? LastUsedAt { get; set; }
/// <summary>
/// Additional metadata about the connection
/// </summary>
[Column(TypeName = "jsonb")]
public Dictionary<string, object>? Meta { 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

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

@ -45,30 +45,3 @@ public enum AuthChallengePlatform
System = 100,
Unknown = 999
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthFactorType
{
Password = 0,
EmailCode = 1,
PhoneCode = 2,
Totp = 3,
WebAuthn = 4,
RecoveryCode = 5,
// Social and federation
Google = 10,
Apple = 11,
Microsoft = 12,
Facebook = 13,
Twitter = 14,
Github = 15,
// Enterprise
Saml = 50,
Oidc = 51,
Ldap = 52,
// Custom factor types
Custom = 100
}

View File

@ -1,10 +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
{
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
/// <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

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace DysonNetwork.Common.Models;

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),
};
}
}