Compare commits
	
		
			4 Commits
		
	
	
		
			ef9175d27d
			...
			8fbc81cab9
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8fbc81cab9 | |||
| 3c11c4f3be | |||
| a03b8d1cac | |||
| cbfdb4aa60 | 
| @@ -3,6 +3,7 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Storage; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Drive.Storage; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
| using Microsoft.EntityFrameworkCore.Migrations; | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										190
									
								
								DysonNetwork.Drive/Migrations/20250715080004_ReinitalMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								DysonNetwork.Drive/Migrations/20250715080004_ReinitalMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,190 @@ | |||||||
|  | // <auto-generated /> | ||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using DysonNetwork.Drive; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
|  | using NodaTime; | ||||||
|  | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     [DbContext(typeof(AppDatabase))] | ||||||
|  |     [Migration("20250715080004_ReinitalMigration")] | ||||||
|  |     partial class ReinitalMigration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||||
|  |         { | ||||||
|  | #pragma warning disable 612, 618 | ||||||
|  |             modelBuilder | ||||||
|  |                 .HasAnnotation("ProductVersion", "9.0.7") | ||||||
|  |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
|  |  | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); | ||||||
|  |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<string>("Id") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Guid>("AccountId") | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("account_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Description") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("HasCompression") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("has_compression"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Hash") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("hash"); | ||||||
|  |  | ||||||
|  |                     b.Property<bool>("IsMarkedRecycle") | ||||||
|  |                         .HasColumnType("boolean") | ||||||
|  |                         .HasColumnName("is_marked_recycle"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("MimeType") | ||||||
|  |                         .HasMaxLength(256) | ||||||
|  |                         .HasColumnType("character varying(256)") | ||||||
|  |                         .HasColumnName("mime_type"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Name") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("name"); | ||||||
|  |  | ||||||
|  |                     b.Property<List<ContentSensitiveMark>>("SensitiveMarks") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("sensitive_marks"); | ||||||
|  |  | ||||||
|  |                     b.Property<long>("Size") | ||||||
|  |                         .HasColumnType("bigint") | ||||||
|  |                         .HasColumnName("size"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageId") | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("storage_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("StorageUrl") | ||||||
|  |                         .HasMaxLength(4096) | ||||||
|  |                         .HasColumnType("character varying(4096)") | ||||||
|  |                         .HasColumnName("storage_url"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("UploadedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("uploaded_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("UploadedTo") | ||||||
|  |                         .HasMaxLength(128) | ||||||
|  |                         .HasColumnType("character varying(128)") | ||||||
|  |                         .HasColumnName("uploaded_to"); | ||||||
|  |  | ||||||
|  |                     b.Property<Dictionary<string, object>>("UserMeta") | ||||||
|  |                         .HasColumnType("jsonb") | ||||||
|  |                         .HasColumnName("user_meta"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_files"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("files", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.Property<Guid>("Id") | ||||||
|  |                         .ValueGeneratedOnAdd() | ||||||
|  |                         .HasColumnType("uuid") | ||||||
|  |                         .HasColumnName("id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("CreatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("created_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("DeletedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("deleted_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant?>("ExpiredAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("expired_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("FileId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(32) | ||||||
|  |                         .HasColumnType("character varying(32)") | ||||||
|  |                         .HasColumnName("file_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("ResourceId") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("resource_id"); | ||||||
|  |  | ||||||
|  |                     b.Property<Instant>("UpdatedAt") | ||||||
|  |                         .HasColumnType("timestamp with time zone") | ||||||
|  |                         .HasColumnName("updated_at"); | ||||||
|  |  | ||||||
|  |                     b.Property<string>("Usage") | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasMaxLength(1024) | ||||||
|  |                         .HasColumnType("character varying(1024)") | ||||||
|  |                         .HasColumnName("usage"); | ||||||
|  |  | ||||||
|  |                     b.HasKey("Id") | ||||||
|  |                         .HasName("pk_file_references"); | ||||||
|  |  | ||||||
|  |                     b.HasIndex("FileId") | ||||||
|  |                         .HasDatabaseName("ix_file_references_file_id"); | ||||||
|  |  | ||||||
|  |                     b.ToTable("file_references", (string)null); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |             modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => | ||||||
|  |                 { | ||||||
|  |                     b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") | ||||||
|  |                         .WithMany() | ||||||
|  |                         .HasForeignKey("FileId") | ||||||
|  |                         .OnDelete(DeleteBehavior.Cascade) | ||||||
|  |                         .IsRequired() | ||||||
|  |                         .HasConstraintName("fk_file_references_files_file_id"); | ||||||
|  |  | ||||||
|  |                     b.Navigation("File"); | ||||||
|  |                 }); | ||||||
|  | #pragma warning restore 612, 618 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,36 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Drive.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class ReinitalMigration : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "file_meta", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: false, | ||||||
|  |                 oldClrType: typeof(Dictionary<string, object>), | ||||||
|  |                 oldType: "jsonb", | ||||||
|  |                 oldNullable: true); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||||
|  |                 name: "file_meta", | ||||||
|  |                 table: "files", | ||||||
|  |                 type: "jsonb", | ||||||
|  |                 nullable: true, | ||||||
|  |                 oldClrType: typeof(Dictionary<string, object>), | ||||||
|  |                 oldType: "jsonb"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using DysonNetwork.Drive; | using DysonNetwork.Drive; | ||||||
| using DysonNetwork.Drive.Storage; | using DysonNetwork.Shared.Data; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | using Microsoft.EntityFrameworkCore.Infrastructure; | ||||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||||
| @@ -51,6 +51,7 @@ namespace DysonNetwork.Drive.Migrations | |||||||
|                         .HasColumnName("description"); |                         .HasColumnName("description"); | ||||||
|  |  | ||||||
|                     b.Property<Dictionary<string, object>>("FileMeta") |                     b.Property<Dictionary<string, object>>("FileMeta") | ||||||
|  |                         .IsRequired() | ||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("file_meta"); |                         .HasColumnName("file_meta"); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ builder.Services.AddAppServices(builder.Configuration); | |||||||
| builder.Services.AddAppRateLimiting(); | builder.Services.AddAppRateLimiting(); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); | builder.Services.AddAppSwagger(); | ||||||
| builder.Services.AddDysonAuth(builder.Configuration); | builder.Services.AddDysonAuth(); | ||||||
|  |  | ||||||
| builder.Services.AddAppFileStorage(builder.Configuration); | builder.Services.AddAppFileStorage(builder.Configuration); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -95,7 +95,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | |||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public string ResourceIdentifier => $"file/{Id}"; |     public string ResourceIdentifier => $"file:{Id}"; | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Converts the CloudFile to a protobuf message |     /// Converts the CloudFile to a protobuf message | ||||||
| @@ -138,23 +138,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| public enum ContentSensitiveMark |  | ||||||
| { |  | ||||||
|     Language, |  | ||||||
|     SexualContent, |  | ||||||
|     Violence, |  | ||||||
|     Profanity, |  | ||||||
|     HateSpeech, |  | ||||||
|     Racism, |  | ||||||
|     AdultContent, |  | ||||||
|     DrugAbuse, |  | ||||||
|     AlcoholAbuse, |  | ||||||
|     Gambling, |  | ||||||
|     SelfHarm, |  | ||||||
|     ChildAbuse, |  | ||||||
|     Other |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class CloudFileReference : ModelBase | public class CloudFileReference : ModelBase | ||||||
| { | { | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
|  | using EFCore.BulkExtensions; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  |  | ||||||
| @@ -23,7 +24,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|         string usage, |         string usage, | ||||||
|         string resourceId, |         string resourceId, | ||||||
|         Instant? expiredAt = null, |         Instant? expiredAt = null, | ||||||
|         Duration? duration = null) |         Duration? duration = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         // Calculate expiration time if needed |         // Calculate expiration time if needed | ||||||
|         var finalExpiration = expiredAt; |         var finalExpiration = expiredAt; | ||||||
| @@ -46,6 +48,25 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach | |||||||
|         return reference; |         return reference; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<CloudFileReference>> CreateReferencesAsync( | ||||||
|  |         List<string> fileId, | ||||||
|  |         string usage, | ||||||
|  |         string resourceId, | ||||||
|  |         Instant? expiredAt = null, | ||||||
|  |         Duration? duration = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var data = fileId.Select(id => new CloudFileReference | ||||||
|  |         { | ||||||
|  |             FileId = id, | ||||||
|  |             Usage = usage, | ||||||
|  |             ResourceId = resourceId, | ||||||
|  |             ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration | ||||||
|  |         }).ToList(); | ||||||
|  |         await db.BulkInsertAsync(data); | ||||||
|  |         return data; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Gets all references to a file |     /// Gets all references to a file | ||||||
|     /// </summary> |     /// </summary> | ||||||
|   | |||||||
| @@ -27,6 +27,27 @@ namespace DysonNetwork.Drive.Storage | |||||||
|             return reference.ToProtoValue(); |             return reference.ToProtoValue(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request, | ||||||
|  |             ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             Instant? expiredAt = null; | ||||||
|  |             if (request.ExpiredAt != null) | ||||||
|  |                 expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds); | ||||||
|  |             else if (request.Duration != null) | ||||||
|  |                 expiredAt = SystemClock.Instance.GetCurrentInstant() + | ||||||
|  |                             Duration.FromTimeSpan(request.Duration.ToTimeSpan()); | ||||||
|  |  | ||||||
|  |             var references = await fileReferenceService.CreateReferencesAsync( | ||||||
|  |                 request.FilesId.ToList(), | ||||||
|  |                 request.Usage, | ||||||
|  |                 request.ResourceId, | ||||||
|  |                 expiredAt | ||||||
|  |             ); | ||||||
|  |             var response = new CreateReferenceBatchResponse(); | ||||||
|  |             response.References.AddRange(references.Select(r => r.ToProtoValue())); | ||||||
|  |             return response; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request, |         public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request, | ||||||
|             ServerCallContext context) |             ServerCallContext context) | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -51,6 +51,51 @@ public class FileService( | |||||||
|         return file; |         return file; | ||||||
|     } |     } | ||||||
|      |      | ||||||
|  |     public async Task<List<CloudFile>> GetFilesAsync(List<string> fileIds) | ||||||
|  |     { | ||||||
|  |         var cachedFiles = new Dictionary<string, CloudFile>(); | ||||||
|  |         var uncachedIds = new List<string>(); | ||||||
|  |  | ||||||
|  |         // Check cache first | ||||||
|  |         foreach (var fileId in fileIds) | ||||||
|  |         { | ||||||
|  |             var cacheKey = $"{CacheKeyPrefix}{fileId}"; | ||||||
|  |             var cachedFile = await cache.GetAsync<CloudFile>(cacheKey); | ||||||
|  |  | ||||||
|  |             if (cachedFile != null) | ||||||
|  |             { | ||||||
|  |                 cachedFiles[fileId] = cachedFile; | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 uncachedIds.Add(fileId); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Load uncached files from database | ||||||
|  |         if (uncachedIds.Count > 0) | ||||||
|  |         { | ||||||
|  |             var dbFiles = await db.Files | ||||||
|  |                 .Where(f => uncachedIds.Contains(f.Id)) | ||||||
|  |                 .ToListAsync(); | ||||||
|  |  | ||||||
|  |             // Add to cache | ||||||
|  |             foreach (var file in dbFiles) | ||||||
|  |             { | ||||||
|  |                 var cacheKey = $"{CacheKeyPrefix}{file.Id}"; | ||||||
|  |                 await cache.SetAsync(cacheKey, file, CacheDuration); | ||||||
|  |                 cachedFiles[file.Id] = file; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Preserve original order | ||||||
|  |         return fileIds | ||||||
|  |             .Select(f => cachedFiles.GetValueOrDefault(f)) | ||||||
|  |             .Where(f => f != null) | ||||||
|  |             .Cast<CloudFile>() | ||||||
|  |             .ToList(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private static readonly string TempFilePrefix = "dyn-cloudfile"; |     private static readonly string TempFilePrefix = "dyn-cloudfile"; | ||||||
|  |  | ||||||
|     private static readonly string[] AnimatedImageTypes = |     private static readonly string[] AnimatedImageTypes = | ||||||
| @@ -155,7 +200,7 @@ public class FileService( | |||||||
|                 try |                 try | ||||||
|                 { |                 { | ||||||
|                     var mediaInfo = await FFProbe.AnalyseAsync(ogFilePath); |                     var mediaInfo = await FFProbe.AnalyseAsync(ogFilePath); | ||||||
|                     file.FileMeta = new Dictionary<string, object> |                     file.FileMeta = new Dictionary<string, object?> | ||||||
|                     { |                     { | ||||||
|                         ["duration"] = mediaInfo.Duration.TotalSeconds, |                         ["duration"] = mediaInfo.Duration.TotalSeconds, | ||||||
|                         ["format_name"] = mediaInfo.Format.FormatName, |                         ["format_name"] = mediaInfo.Format.FormatName, | ||||||
|   | |||||||
| @@ -12,6 +12,12 @@ namespace DysonNetwork.Drive.Storage | |||||||
|             return file?.ToProtoValue() ?? throw new RpcException(new Status(StatusCode.NotFound, "File not found")); |             return file?.ToProtoValue() ?? throw new RpcException(new Status(StatusCode.NotFound, "File not found")); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         public override async Task<GetFileBatchResponse> GetFileBatch(GetFileBatchRequest request, ServerCallContext context) | ||||||
|  |         { | ||||||
|  |             var files = await fileService.GetFilesAsync(request.Ids.ToList()); | ||||||
|  |             return new GetFileBatchResponse { Files = { files.Select(f => f.ToProtoValue()) } }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public override async Task<Shared.Proto.CloudFile> UpdateFile(UpdateFileRequest request, |         public override async Task<Shared.Proto.CloudFile> UpdateFile(UpdateFileRequest request, | ||||||
|             ServerCallContext context) |             ServerCallContext context) | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account; | |||||||
|  |  | ||||||
| public class AccountServiceGrpc( | public class AccountServiceGrpc( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|  |     RelationshipService relationships, | ||||||
|     IClock clock, |     IClock clock, | ||||||
|     ILogger<AccountServiceGrpc> logger |     ILogger<AccountServiceGrpc> logger | ||||||
| ) | ) | ||||||
| @@ -36,7 +37,8 @@ public class AccountServiceGrpc( | |||||||
|         return account.ToProtoValue(); |         return account.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, ServerCallContext context) |     public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|     { |     { | ||||||
|         var accountIds = request.Id |         var accountIds = request.Id | ||||||
|             .Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null) |             .Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null) | ||||||
| @@ -54,68 +56,6 @@ public class AccountServiceGrpc( | |||||||
|         return response; |         return response; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request, |  | ||||||
|         ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         // Map protobuf request to domain model |  | ||||||
|         var account = new Account |  | ||||||
|         { |  | ||||||
|             Name = request.Name, |  | ||||||
|             Nick = request.Nick, |  | ||||||
|             Language = request.Language, |  | ||||||
|             IsSuperuser = request.IsSuperuser, |  | ||||||
|             ActivatedAt = request.Profile != null ? null : _clock.GetCurrentInstant(), |  | ||||||
|             Profile = new AccountProfile |  | ||||||
|             { |  | ||||||
|                 FirstName = request.Profile?.FirstName, |  | ||||||
|                 LastName = request.Profile?.LastName, |  | ||||||
|                 // Initialize other profile fields as needed |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Add to database |  | ||||||
|         _db.Accounts.Add(account); |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         _logger.LogInformation("Created new account with ID {AccountId}", account.Id); |  | ||||||
|         return account.ToProtoValue(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.Account> UpdateAccount(UpdateAccountRequest request, |  | ||||||
|         ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.Id, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         var account = await _db.Accounts.FindAsync(accountId); |  | ||||||
|         if (account == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); |  | ||||||
|  |  | ||||||
|         // Update fields if they are provided in the request |  | ||||||
|         if (request.Name != null) account.Name = request.Name; |  | ||||||
|         if (request.Nick != null) account.Nick = request.Nick; |  | ||||||
|         if (request.Language != null) account.Language = request.Language; |  | ||||||
|         if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value; |  | ||||||
|  |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|         return account.ToProtoValue(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<Empty> DeleteAccount(DeleteAccountRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.Id, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         var account = await _db.Accounts.FindAsync(accountId); |  | ||||||
|         if (account == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); |  | ||||||
|  |  | ||||||
|         _db.Accounts.Remove(account); |  | ||||||
|  |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|         return new Empty(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, |     public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request, | ||||||
|         ServerCallContext context) |         ServerCallContext context) | ||||||
|     { |     { | ||||||
| @@ -159,204 +99,54 @@ public class AccountServiceGrpc( | |||||||
|         return response; |         return response; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| // Implement other service methods following the same pattern... |     public override async Task<ListRelationshipSimpleResponse> ListFriends( | ||||||
|  |         ListRelationshipSimpleRequest request, ServerCallContext context) | ||||||
| // Profile operations |  | ||||||
|     public override async Task<Shared.Proto.AccountProfile> GetProfile(GetProfileRequest request, |  | ||||||
|         ServerCallContext context) |  | ||||||
|     { |     { | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |         var accountId = Guid.Parse(request.AccountId); | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |         var relationship = await relationships.ListAccountFriends(accountId); | ||||||
|  |         var resp = new ListRelationshipSimpleResponse(); | ||||||
|         var profile = await _db.AccountProfiles |         resp.AccountsId.AddRange(relationship.Select(x => x.ToString())); | ||||||
|             .AsNoTracking() |         return resp; | ||||||
|             .FirstOrDefaultAsync(p => p.AccountId == accountId); |  | ||||||
|  |  | ||||||
|         if (profile == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, |  | ||||||
|                 $"Profile for account {request.AccountId} not found")); |  | ||||||
|  |  | ||||||
|         return profile.ToProtoValue(); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.AccountProfile> UpdateProfile(UpdateProfileRequest request, |     public override async Task<ListRelationshipSimpleResponse> ListBlocked( | ||||||
|         ServerCallContext context) |         ListRelationshipSimpleRequest request, ServerCallContext context) | ||||||
|     { |     { | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |         var accountId = Guid.Parse(request.AccountId); | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |         var relationship = await relationships.ListAccountBlocked(accountId); | ||||||
|  |         var resp = new ListRelationshipSimpleResponse(); | ||||||
|         var profile = await _db.AccountProfiles |         resp.AccountsId.AddRange(relationship.Select(x => x.ToString())); | ||||||
|             .FirstOrDefaultAsync(p => p.AccountId == accountId); |         return resp; | ||||||
|  |  | ||||||
|         if (profile == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, |  | ||||||
|                 $"Profile for account {request.AccountId} not found")); |  | ||||||
|  |  | ||||||
|         // Update only the fields specified in the field mask |  | ||||||
|         if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("first_name")) |  | ||||||
|             profile.FirstName = request.Profile.FirstName; |  | ||||||
|  |  | ||||||
|         if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("last_name")) |  | ||||||
|             profile.LastName = request.Profile.LastName; |  | ||||||
|  |  | ||||||
|         // Update other fields similarly... |  | ||||||
|  |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|         return profile.ToProtoValue(); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
| // Contact operations |     public override async Task<GetRelationshipResponse> GetRelationship(GetRelationshipRequest request, | ||||||
|     public override async Task<Shared.Proto.AccountContact> AddContact(AddContactRequest request, |  | ||||||
|         ServerCallContext context) |         ServerCallContext context) | ||||||
|     { |     { | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |         var relationship = await relationships.GetRelationship( | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |             Guid.Parse(request.AccountId), | ||||||
|  |             Guid.Parse(request.RelatedId), | ||||||
|         var contact = new AccountContact |             status: (RelationshipStatus?)request.Status | ||||||
|  |         ); | ||||||
|  |         return new GetRelationshipResponse | ||||||
|         { |         { | ||||||
|             AccountId = accountId, |             Relationship = relationship?.ToProtoValue() | ||||||
|             Type = (AccountContactType)request.Type, |  | ||||||
|             Content = request.Content, |  | ||||||
|             IsPrimary = request.IsPrimary, |  | ||||||
|             VerifiedAt = null |  | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         _db.AccountContacts.Add(contact); |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return contact.ToProtoValue(); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Empty> RemoveContact(RemoveContactRequest request, ServerCallContext context) |     public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context) | ||||||
|     { |     { | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |         var hasRelationship = false; | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |         if (!request.HasStatus) | ||||||
|  |             hasRelationship = await relationships.HasExistingRelationship( | ||||||
|         if (!Guid.TryParse(request.Id, out var contactId)) |                 Guid.Parse(request.AccountId), | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid contact ID format")); |                 Guid.Parse(request.RelatedId) | ||||||
|  |             ); | ||||||
|         var contact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.Id == contactId && c.AccountId == accountId); |         else | ||||||
|         if (contact == null) |             hasRelationship = await relationships.HasRelationshipWithStatus( | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Contact not found.")); |                 Guid.Parse(request.AccountId), | ||||||
|  |                 Guid.Parse(request.RelatedId), | ||||||
|         _db.AccountContacts.Remove(contact); |                 (RelationshipStatus)request.Status | ||||||
|         await _db.SaveChangesAsync(); |             ); | ||||||
|  |         return new BoolValue { Value = hasRelationship }; | ||||||
|         return new Empty(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<ListContactsResponse> ListContacts(ListContactsRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         var query = _db.AccountContacts.AsNoTracking().Where(c => c.AccountId == accountId); |  | ||||||
|  |  | ||||||
|         if (request.VerifiedOnly) |  | ||||||
|             query = query.Where(c => c.VerifiedAt != null); |  | ||||||
|  |  | ||||||
|         var contacts = await query.ToListAsync(); |  | ||||||
|  |  | ||||||
|         var response = new ListContactsResponse(); |  | ||||||
|         response.Contacts.AddRange(contacts.Select(c => c.ToProtoValue())); |  | ||||||
|  |  | ||||||
|         return response; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.AccountContact> VerifyContact(VerifyContactRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         // This is a placeholder implementation. In a real-world scenario, you would |  | ||||||
|         // have a more robust verification mechanism (e.g., sending a code to the |  | ||||||
|         // user's email or phone). |  | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         if (!Guid.TryParse(request.Id, out var contactId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid contact ID format")); |  | ||||||
|  |  | ||||||
|         var contact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.Id == contactId && c.AccountId == accountId); |  | ||||||
|         if (contact == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Contact not found.")); |  | ||||||
|  |  | ||||||
|         contact.VerifiedAt = _clock.GetCurrentInstant(); |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return contact.ToProtoValue(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| // Badge operations |  | ||||||
|     public override async Task<Shared.Proto.AccountBadge> AddBadge(AddBadgeRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         var badge = new AccountBadge |  | ||||||
|         { |  | ||||||
|             AccountId = accountId, |  | ||||||
|             Type = request.Type, |  | ||||||
|             Label = request.Label, |  | ||||||
|             Caption = request.Caption, |  | ||||||
|             ActivatedAt = _clock.GetCurrentInstant(), |  | ||||||
|             ExpiredAt = request.ExpiredAt?.ToInstant(), |  | ||||||
|             Meta = request.Meta.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value) |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         _db.Badges.Add(badge); |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return badge.ToProtoValue(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<Empty> RemoveBadge(RemoveBadgeRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         if (!Guid.TryParse(request.Id, out var badgeId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid badge ID format")); |  | ||||||
|  |  | ||||||
|         var badge = await _db.Badges.FirstOrDefaultAsync(b => b.Id == badgeId && b.AccountId == accountId); |  | ||||||
|         if (badge == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Badge not found.")); |  | ||||||
|  |  | ||||||
|         _db.Badges.Remove(badge); |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return new Empty(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<ListBadgesResponse> ListBadges(ListBadgesRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         var query = _db.Badges.AsNoTracking().Where(b => b.AccountId == accountId); |  | ||||||
|  |  | ||||||
|         if (request.ActiveOnly) |  | ||||||
|             query = query.Where(b => b.ExpiredAt == null || b.ExpiredAt > _clock.GetCurrentInstant()); |  | ||||||
|  |  | ||||||
|         var badges = await query.ToListAsync(); |  | ||||||
|  |  | ||||||
|         var response = new ListBadgesResponse(); |  | ||||||
|         response.Badges.AddRange(badges.Select(b => b.ToProtoValue())); |  | ||||||
|  |  | ||||||
|         return response; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.AccountProfile> SetActiveBadge(SetActiveBadgeRequest request, ServerCallContext context) |  | ||||||
|     { |  | ||||||
|         if (!Guid.TryParse(request.AccountId, out var accountId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); |  | ||||||
|  |  | ||||||
|         var profile = await _db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); |  | ||||||
|         if (profile == null) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Profile not found.")); |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrEmpty(request.BadgeId) && !Guid.TryParse(request.BadgeId, out var badgeId)) |  | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid badge ID format")); |  | ||||||
|  |  | ||||||
|         await _db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return profile.ToProtoValue(); |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,49 +1,12 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
| using Point = NetTopologySuite.Geometries.Point; | using Point = NetTopologySuite.Geometries.Point; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
| public abstract class ActionLogType |  | ||||||
| { |  | ||||||
|     public const string NewLogin = "login"; |  | ||||||
|     public const string ChallengeAttempt = "challenges.attempt"; |  | ||||||
|     public const string ChallengeSuccess = "challenges.success"; |  | ||||||
|     public const string ChallengeFailure = "challenges.failure"; |  | ||||||
|     public const string PostCreate = "posts.create"; |  | ||||||
|     public const string PostUpdate = "posts.update"; |  | ||||||
|     public const string PostDelete = "posts.delete"; |  | ||||||
|     public const string PostReact = "posts.react"; |  | ||||||
|     public const string MessageCreate = "messages.create"; |  | ||||||
|     public const string MessageUpdate = "messages.update"; |  | ||||||
|     public const string MessageDelete = "messages.delete"; |  | ||||||
|     public const string MessageReact = "messages.react"; |  | ||||||
|     public const string PublisherCreate = "publishers.create"; |  | ||||||
|     public const string PublisherUpdate = "publishers.update"; |  | ||||||
|     public const string PublisherDelete = "publishers.delete"; |  | ||||||
|     public const string PublisherMemberInvite = "publishers.members.invite"; |  | ||||||
|     public const string PublisherMemberJoin = "publishers.members.join"; |  | ||||||
|     public const string PublisherMemberLeave = "publishers.members.leave"; |  | ||||||
|     public const string PublisherMemberKick = "publishers.members.kick"; |  | ||||||
|     public const string RealmCreate = "realms.create"; |  | ||||||
|     public const string RealmUpdate = "realms.update"; |  | ||||||
|     public const string RealmDelete = "realms.delete"; |  | ||||||
|     public const string RealmInvite = "realms.invite"; |  | ||||||
|     public const string RealmJoin = "realms.join"; |  | ||||||
|     public const string RealmLeave = "realms.leave"; |  | ||||||
|     public const string RealmKick = "realms.kick"; |  | ||||||
|     public const string RealmAdjustRole = "realms.role.edit"; |  | ||||||
|     public const string ChatroomCreate = "chatrooms.create"; |  | ||||||
|     public const string ChatroomUpdate = "chatrooms.update"; |  | ||||||
|     public const string ChatroomDelete = "chatrooms.delete"; |  | ||||||
|     public const string ChatroomInvite = "chatrooms.invite"; |  | ||||||
|     public const string ChatroomJoin = "chatrooms.join"; |  | ||||||
|     public const string ChatroomLeave = "chatrooms.leave"; |  | ||||||
|     public const string ChatroomKick = "chatrooms.kick"; |  | ||||||
|     public const string ChatroomAdjustRole = "chatrooms.role.edit"; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class ActionLog : ModelBase | public class ActionLog : ModelBase | ||||||
| { | { | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
| @@ -56,4 +19,26 @@ public class ActionLog : ModelBase | |||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     public Account Account { get; set; } = null!; |     public Account Account { get; set; } = null!; | ||||||
|     public Guid? SessionId { get; set; } |     public Guid? SessionId { get; set; } | ||||||
|  |  | ||||||
|  |     public Shared.Proto.ActionLog ToProtoValue() | ||||||
|  |     { | ||||||
|  |         var protoLog = new Shared.Proto.ActionLog | ||||||
|  |         { | ||||||
|  |             Id = Id.ToString(), | ||||||
|  |             Action = Action, | ||||||
|  |             UserAgent = UserAgent ?? string.Empty, | ||||||
|  |             IpAddress = IpAddress ?? string.Empty, | ||||||
|  |             Location = Location?.ToString() ?? string.Empty, | ||||||
|  |             AccountId = AccountId.ToString(), | ||||||
|  |             CreatedAt = CreatedAt.ToTimestamp() | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         // Convert Meta dictionary to Struct | ||||||
|  |         protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta)); | ||||||
|  |  | ||||||
|  |         if (SessionId.HasValue) | ||||||
|  |             protoLog.SessionId = SessionId.Value.ToString(); | ||||||
|  |  | ||||||
|  |         return protoLog; | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -5,7 +5,7 @@ namespace DysonNetwork.Pass.Account; | |||||||
|  |  | ||||||
| public class ActionLogService(GeoIpService geo, FlushBufferService fbs) | public class ActionLogService(GeoIpService geo, FlushBufferService fbs) | ||||||
| { | { | ||||||
|     public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta) |     public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta) | ||||||
|     { |     { | ||||||
|         var log = new ActionLog |         var log = new ActionLog | ||||||
|         { |         { | ||||||
|   | |||||||
							
								
								
									
										114
									
								
								DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,114 @@ | |||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
|  | public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServiceBase | ||||||
|  | { | ||||||
|  |     private readonly ActionLogService _actionLogService; | ||||||
|  |     private readonly AppDatabase _db; | ||||||
|  |     private readonly ILogger<ActionLogServiceGrpc> _logger; | ||||||
|  |  | ||||||
|  |     public ActionLogServiceGrpc( | ||||||
|  |         ActionLogService actionLogService, | ||||||
|  |         AppDatabase db, | ||||||
|  |         ILogger<ActionLogServiceGrpc> logger) | ||||||
|  |     { | ||||||
|  |         _actionLogService = actionLogService ?? throw new ArgumentNullException(nameof(actionLogService)); | ||||||
|  |         _db = db ?? throw new ArgumentNullException(nameof(db)); | ||||||
|  |         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<CreateActionLogResponse> CreateActionLog(CreateActionLogRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId)) | ||||||
|  |         { | ||||||
|  |             throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID")); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var meta = request.Meta | ||||||
|  |                 ?.Select(x => new KeyValuePair<string, object?>(x.Key, GrpcTypeHelper.ConvertField(x.Value))) | ||||||
|  |                 .ToDictionary() ?? new Dictionary<string, object?>(); | ||||||
|  |  | ||||||
|  |             _actionLogService.CreateActionLog( | ||||||
|  |                 accountId, | ||||||
|  |                 request.Action, | ||||||
|  |                 meta | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             return new CreateActionLogResponse(); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             _logger.LogError(ex, "Error creating action log"); | ||||||
|  |             throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to create action log")); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<ListActionLogsResponse> ListActionLogs(ListActionLogsRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId)) | ||||||
|  |         { | ||||||
|  |             throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID")); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var query = _db.ActionLogs | ||||||
|  |                 .AsNoTracking() | ||||||
|  |                 .Where(log => log.AccountId == accountId); | ||||||
|  |  | ||||||
|  |             if (!string.IsNullOrEmpty(request.Action)) | ||||||
|  |             { | ||||||
|  |                 query = query.Where(log => log.Action == request.Action); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Apply ordering (default to newest first) | ||||||
|  |             query = (request.OrderBy?.ToLower() ?? "createdat desc") switch | ||||||
|  |             { | ||||||
|  |                 "createdat" => query.OrderBy(log => log.CreatedAt), | ||||||
|  |                 "createdat desc" => query.OrderByDescending(log => log.CreatedAt), | ||||||
|  |                 _ => query.OrderByDescending(log => log.CreatedAt) | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             // Apply pagination | ||||||
|  |             var pageSize = request.PageSize == 0 ? 50 : Math.Min(request.PageSize, 1000); | ||||||
|  |             var logs = await query | ||||||
|  |                 .Take(pageSize + 1) // Fetch one extra to determine if there are more pages | ||||||
|  |                 .ToListAsync(); | ||||||
|  |  | ||||||
|  |             var hasMore = logs.Count > pageSize; | ||||||
|  |             if (hasMore) | ||||||
|  |             { | ||||||
|  |                 logs.RemoveAt(logs.Count - 1); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var response = new ListActionLogsResponse | ||||||
|  |             { | ||||||
|  |                 TotalSize = await query.CountAsync() | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             if (hasMore) | ||||||
|  |             { | ||||||
|  |                 // In a real implementation, you'd generate a proper page token | ||||||
|  |                 response.NextPageToken = (logs.LastOrDefault()?.CreatedAt ?? SystemClock.Instance.GetCurrentInstant()) | ||||||
|  |                     .ToString(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             response.ActionLogs.AddRange(logs.Select(log => log.ToProtoValue())); | ||||||
|  |  | ||||||
|  |             return response; | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             _logger.LogError(ex, "Error listing action logs"); | ||||||
|  |             throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs")); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
| @@ -20,4 +22,15 @@ public class Relationship : ModelBase | |||||||
|     public Instant? ExpiredAt { get; set; } |     public Instant? ExpiredAt { get; set; } | ||||||
|  |  | ||||||
|     public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending; |     public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending; | ||||||
|  |      | ||||||
|  |     public Shared.Proto.Relationship ToProtoValue() => new() | ||||||
|  |     { | ||||||
|  |         AccountId = AccountId.ToString(), | ||||||
|  |         RelatedId = RelatedId.ToString(), | ||||||
|  |         Account = Account.ToProtoValue(), | ||||||
|  |         Related = Related.ToProtoValue(), | ||||||
|  |         Status = (int)Status, | ||||||
|  |         CreatedAt = CreatedAt.ToTimestamp(), | ||||||
|  |         UpdatedAt = UpdatedAt.ToTimestamp() | ||||||
|  |     }; | ||||||
| } | } | ||||||
| @@ -154,13 +154,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|  |  | ||||||
|     public async Task<List<Guid>> ListAccountFriends(Account account) |     public async Task<List<Guid>> ListAccountFriends(Account account) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; |         return await ListAccountFriends(account.Id); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task<List<Guid>> ListAccountFriends(Guid accountId) | ||||||
|  |     { | ||||||
|  |         var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}"; | ||||||
|         var friends = await cache.GetAsync<List<Guid>>(cacheKey); |         var friends = await cache.GetAsync<List<Guid>>(cacheKey); | ||||||
|          |          | ||||||
|         if (friends == null) |         if (friends == null) | ||||||
|         { |         { | ||||||
|             friends = await db.AccountRelationships |             friends = await db.AccountRelationships | ||||||
|                 .Where(r => r.RelatedId == account.Id) |                 .Where(r => r.RelatedId == accountId) | ||||||
|                 .Where(r => r.Status == RelationshipStatus.Friends) |                 .Where(r => r.Status == RelationshipStatus.Friends) | ||||||
|                 .Select(r => r.AccountId) |                 .Select(r => r.AccountId) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
| @@ -173,13 +178,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache) | |||||||
|      |      | ||||||
|     public async Task<List<Guid>> ListAccountBlocked(Account account) |     public async Task<List<Guid>> ListAccountBlocked(Account account) | ||||||
|     { |     { | ||||||
|         var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}"; |         return await ListAccountBlocked(account.Id); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     public async Task<List<Guid>> ListAccountBlocked(Guid accountId) | ||||||
|  |     { | ||||||
|  |         var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}"; | ||||||
|         var blocked = await cache.GetAsync<List<Guid>>(cacheKey); |         var blocked = await cache.GetAsync<List<Guid>>(cacheKey); | ||||||
|          |          | ||||||
|         if (blocked == null) |         if (blocked == null) | ||||||
|         { |         { | ||||||
|             blocked = await db.AccountRelationships |             blocked = await db.AccountRelationships | ||||||
|                 .Where(r => r.RelatedId == account.Id) |                 .Where(r => r.RelatedId == accountId) | ||||||
|                 .Where(r => r.Status == RelationshipStatus.Blocked) |                 .Where(r => r.Status == RelationshipStatus.Blocked) | ||||||
|                 .Select(r => r.AccountId) |                 .Select(r => r.AccountId) | ||||||
|                 .ToListAsync(); |                 .ToListAsync(); | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
| using DysonNetwork.Shared.GeoIp; | using DysonNetwork.Shared.GeoIp; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Auth; | namespace DysonNetwork.Pass.Auth; | ||||||
|   | |||||||
| @@ -46,4 +46,17 @@ public class AuthServiceGrpc( | |||||||
|  |  | ||||||
|         return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() }; |         return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() }; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var accountId = Guid.Parse(request.AccountId); | ||||||
|  |         var valid = await authService.ValidatePinCode(accountId, request.Pin); | ||||||
|  |         return new ValidateResponse { Valid = valid }; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var valid = await authService.ValidateCaptcha(request.Token); | ||||||
|  |         return new ValidateResponse { Valid = valid }; | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -34,7 +34,7 @@ public class CustomApp : ModelBase, IIdentifiedResource | |||||||
|  |  | ||||||
|     // TODO: Publisher |     // TODO: Publisher | ||||||
|  |  | ||||||
|     [NotMapped] public string ResourceIdentifier => "custom-app/" + Id; |     [NotMapped] public string ResourceIdentifier => "custom-app:" + Id; | ||||||
| } | } | ||||||
|  |  | ||||||
| public class CustomAppLinks | public class CustomAppLinks | ||||||
|   | |||||||
| @@ -100,12 +100,16 @@ | |||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |       <AdditionalFiles Include="Pages\Checkpoint\CheckpointPage.cshtml" /> | ||||||
|       <AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" /> |       <AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" /> | ||||||
|       <AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" /> |       <AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" /> | ||||||
|       <AdditionalFiles Include="Pages\Emails\EmailLayout.razor" /> |       <AdditionalFiles Include="Pages\Emails\EmailLayout.razor" /> | ||||||
|       <AdditionalFiles Include="Pages\Emails\LandingEmail.razor" /> |       <AdditionalFiles Include="Pages\Emails\LandingEmail.razor" /> | ||||||
|       <AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" /> |       <AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" /> | ||||||
|       <AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" /> |       <AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" /> | ||||||
|  |       <AdditionalFiles Include="Pages\Shared\_Layout.cshtml" /> | ||||||
|  |       <AdditionalFiles Include="Pages\Shared\_ValidationScriptsPartial.cshtml" /> | ||||||
|  |       <AdditionalFiles Include="Pages\Spell\MagicSpellPage.cshtml" /> | ||||||
|       <AdditionalFiles Include="Resources\Localization\AccountEventResource.resx" /> |       <AdditionalFiles Include="Resources\Localization\AccountEventResource.resx" /> | ||||||
|       <AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx" /> |       <AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx" /> | ||||||
|       <AdditionalFiles Include="Resources\Localization\EmailResource.resx" /> |       <AdditionalFiles Include="Resources\Localization\EmailResource.resx" /> | ||||||
|   | |||||||
							
								
								
									
										1967
									
								
								DysonNetwork.Pass/Migrations/20250715075623_ReinitalMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1967
									
								
								DysonNetwork.Pass/Migrations/20250715075623_ReinitalMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | using Microsoft.EntityFrameworkCore.Migrations; | ||||||
|  |  | ||||||
|  | #nullable disable | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Migrations | ||||||
|  | { | ||||||
|  |     /// <inheritdoc /> | ||||||
|  |     public partial class ReinitalMigration : Migration | ||||||
|  |     { | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Up(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "background_id", | ||||||
|  |                 table: "account_profiles"); | ||||||
|  |  | ||||||
|  |             migrationBuilder.DropColumn( | ||||||
|  |                 name: "picture_id", | ||||||
|  |                 table: "account_profiles"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         /// <inheritdoc /> | ||||||
|  |         protected override void Down(MigrationBuilder migrationBuilder) | ||||||
|  |         { | ||||||
|  |             migrationBuilder.AddColumn<string>( | ||||||
|  |                 name: "background_id", | ||||||
|  |                 table: "account_profiles", | ||||||
|  |                 type: "character varying(32)", | ||||||
|  |                 maxLength: 32, | ||||||
|  |                 nullable: true); | ||||||
|  |  | ||||||
|  |             migrationBuilder.AddColumn<string>( | ||||||
|  |                 name: "picture_id", | ||||||
|  |                 table: "account_profiles", | ||||||
|  |                 type: "character varying(32)", | ||||||
|  |                 maxLength: 32, | ||||||
|  |                 nullable: true); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -392,11 +392,6 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("background"); |                         .HasColumnName("background"); | ||||||
|  |  | ||||||
|                     b.Property<string>("BackgroundId") |  | ||||||
|                         .HasMaxLength(32) |  | ||||||
|                         .HasColumnType("character varying(32)") |  | ||||||
|                         .HasColumnName("background_id"); |  | ||||||
|  |  | ||||||
|                     b.Property<string>("Bio") |                     b.Property<string>("Bio") | ||||||
|                         .HasMaxLength(4096) |                         .HasMaxLength(4096) | ||||||
|                         .HasColumnType("character varying(4096)") |                         .HasColumnType("character varying(4096)") | ||||||
| @@ -451,11 +446,6 @@ namespace DysonNetwork.Pass.Migrations | |||||||
|                         .HasColumnType("jsonb") |                         .HasColumnType("jsonb") | ||||||
|                         .HasColumnName("picture"); |                         .HasColumnName("picture"); | ||||||
|  |  | ||||||
|                     b.Property<string>("PictureId") |  | ||||||
|                         .HasMaxLength(32) |  | ||||||
|                         .HasColumnType("character varying(32)") |  | ||||||
|                         .HasColumnName("picture_id"); |  | ||||||
|  |  | ||||||
|                     b.Property<string>("Pronouns") |                     b.Property<string>("Pronouns") | ||||||
|                         .HasMaxLength(1024) |                         .HasMaxLength(1024) | ||||||
|                         .HasColumnType("character varying(1024)") |                         .HasColumnType("character varying(1024)") | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| @page "/auth/captcha" | @page "/auth/captcha" | ||||||
| @model DysonNetwork.Sphere.Pages.Checkpoint.CheckpointPage | @model DysonNetwork.Pass.Pages.Checkpoint.CheckpointPage | ||||||
| 
 | 
 | ||||||
| @{ | @{ | ||||||
|     ViewData["Title"] = "Security Checkpoint"; |     ViewData["Title"] = "Security Checkpoint"; | ||||||
| @@ -99,7 +99,7 @@ | |||||||
|                             <br/> |                             <br/> | ||||||
|                             Hosted by |                             Hosted by | ||||||
|                             <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover"> |                             <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover"> | ||||||
|                                 DysonNetwork.Sphere |                                 DysonNetwork.Pass | ||||||
|                             </a> |                             </a> | ||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | using Microsoft.AspNetCore.Mvc.RazorPages; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Sphere.Pages.Checkpoint; | namespace DysonNetwork.Pass.Pages.Checkpoint; | ||||||
| 
 | 
 | ||||||
| public class CheckpointPage(IConfiguration configuration) : PageModel | public class CheckpointPage(IConfiguration configuration) : PageModel | ||||||
| { | { | ||||||
							
								
								
									
										62
									
								
								DysonNetwork.Pass/Pages/Shared/_Layout.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								DysonNetwork.Pass/Pages/Shared/_Layout.cshtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | |||||||
|  | @using DysonNetwork.Pass.Auth | ||||||
|  |  | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en" class="h-full"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="utf-8"/> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | ||||||
|  |     <title>@ViewData["Title"]</title> | ||||||
|  |     <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|  |     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|  |     <link | ||||||
|  |         href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" | ||||||
|  |         rel="stylesheet"> | ||||||
|  |     <link rel="stylesheet" href="~/css/styles.css" asp-append-version="true"/> | ||||||
|  |     <link | ||||||
|  |         href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" | ||||||
|  |         rel="stylesheet" | ||||||
|  |     /> | ||||||
|  |      | ||||||
|  |     @await RenderSectionAsync("Head", required: false) | ||||||
|  | </head> | ||||||
|  | <body class="h-full bg-base-200"> | ||||||
|  | <header class="navbar bg-base-100/35 backdrop-blur-md shadow-xl fixed left-0 right-0 top-0 z-50 px-5"> | ||||||
|  |     <div class="flex-1"> | ||||||
|  |         <a class="btn btn-ghost text-xl">Solar Network</a> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex-none"> | ||||||
|  |         <ul class="menu menu-horizontal menu-sm px-1"> | ||||||
|  |             @if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _)) | ||||||
|  |             { | ||||||
|  |                 <li class="tooltip tooltip-bottom" data-tip="Profile"> | ||||||
|  |                     <a href="//account/profile"> | ||||||
|  |                         <span class="material-symbols-outlined">account_circle</span> | ||||||
|  |                     </a> | ||||||
|  |                 </li> | ||||||
|  |                 <li class="tooltip tooltip-bottom" data-tip="Logout"> | ||||||
|  |                     <form method="post" asp-page="/Account/Profile" asp-page-handler="Logout"> | ||||||
|  |                         <button type="submit"> | ||||||
|  |                             <span class="material-symbols-outlined"> | ||||||
|  |                                 logout | ||||||
|  |                             </span> | ||||||
|  |                         </button> | ||||||
|  |                     </form> | ||||||
|  |                 </li> | ||||||
|  |             } | ||||||
|  |             else | ||||||
|  |             { | ||||||
|  |                 <li class="tooltip tooltip-bottom" data-tip="Login"> | ||||||
|  |                     <a href="//auth/login"><span class="material-symbols-outlined">login</span></a> | ||||||
|  |                 </li> | ||||||
|  |             } | ||||||
|  |         </ul> | ||||||
|  |     </div> | ||||||
|  | </header> | ||||||
|  |  | ||||||
|  | <main class="h-full pt-16"> | ||||||
|  |     @RenderBody() | ||||||
|  | </main> | ||||||
|  |  | ||||||
|  | @await RenderSectionAsync("Scripts", required: false) | ||||||
|  | </body> | ||||||
|  | </html>  | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| @page "/spells/{spellWord}" | @page "/spells/{spellWord}" | ||||||
| @using DysonNetwork.Sphere.Account | @using DysonNetwork.Pass.Account | ||||||
| @model DysonNetwork.Sphere.Pages.Spell.MagicSpellPage | @model DysonNetwork.Pass.Pages.Spell.MagicSpellPage | ||||||
| 
 | 
 | ||||||
| @{ | @{ | ||||||
|     ViewData["Title"] = "Magic Spell"; |     ViewData["Title"] = "Magic Spell"; | ||||||
| @@ -82,7 +82,7 @@ | |||||||
|                     <br/> |                     <br/> | ||||||
|                     Powered by |                     Powered by | ||||||
|                     <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover"> |                     <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover"> | ||||||
|                         DysonNetwork.Sphere |                         DysonNetwork.Pass | ||||||
|                     </a> |                     </a> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
| @@ -1,10 +1,10 @@ | |||||||
| using DysonNetwork.Sphere.Account; | using DysonNetwork.Pass.Account; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.AspNetCore.Mvc.RazorPages; | using Microsoft.AspNetCore.Mvc.RazorPages; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Sphere.Pages.Spell; | namespace DysonNetwork.Pass.Pages.Spell; | ||||||
| 
 | 
 | ||||||
| public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageModel | public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageModel | ||||||
| { | { | ||||||
							
								
								
									
										2
									
								
								DysonNetwork.Pass/Pages/_ViewImports.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								DysonNetwork.Pass/Pages/_ViewImports.cshtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | @namespace DysonNetwork.Pass.Pages | ||||||
|  | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers  | ||||||
							
								
								
									
										3
									
								
								DysonNetwork.Pass/Pages/_ViewStart.cshtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								DysonNetwork.Pass/Pages/_ViewStart.cshtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | @{ | ||||||
|  |     Layout = "_Layout"; | ||||||
|  | }  | ||||||
| @@ -1,5 +1,4 @@ | |||||||
| using DysonNetwork.Pass; | using DysonNetwork.Pass; | ||||||
| using DysonNetwork.Pass.Account; |  | ||||||
| using DysonNetwork.Pass.Startup; | using DysonNetwork.Pass.Startup; | ||||||
| using DysonNetwork.Shared.Http; | using DysonNetwork.Shared.Http; | ||||||
| using DysonNetwork.Shared.Registry; | using DysonNetwork.Shared.Registry; | ||||||
|   | |||||||
| @@ -1,10 +1,10 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using DysonNetwork.Sphere.Account; | using DysonNetwork.Pass.Account; | ||||||
| using DysonNetwork.Sphere.Permission; | using DysonNetwork.Pass.Permission; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Sphere.Safety; | namespace DysonNetwork.Pass.Safety; | ||||||
| 
 | 
 | ||||||
| [ApiController] | [ApiController] | ||||||
| [Route("/api/safety/reports")] | [Route("/api/safety/reports")] | ||||||
| @@ -1,8 +1,8 @@ | |||||||
| using DysonNetwork.Sphere.Account; | using DysonNetwork.Pass.Account; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Sphere.Safety; | namespace DysonNetwork.Pass.Safety; | ||||||
| 
 | 
 | ||||||
| public class SafetyService(AppDatabase db, ILogger<SafetyService> logger) | public class SafetyService(AppDatabase db, ILogger<SafetyService> logger) | ||||||
| { | { | ||||||
| @@ -70,6 +70,7 @@ public static class ApplicationConfiguration | |||||||
|     { |     { | ||||||
|         app.MapGrpcService<AccountServiceGrpc>(); |         app.MapGrpcService<AccountServiceGrpc>(); | ||||||
|         app.MapGrpcService<AuthServiceGrpc>(); |         app.MapGrpcService<AuthServiceGrpc>(); | ||||||
|  |         app.MapGrpcService<ActionLogServiceGrpc>(); | ||||||
|          |          | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -217,7 +217,7 @@ public class PaymentService( | |||||||
|                     Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], |                     Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], | ||||||
|                     Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, |                     Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, | ||||||
|                         readableOrderRemark], |                         readableOrderRemark], | ||||||
|                     IsSavable = false |                     IsSavable = true | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         ); |         ); | ||||||
|   | |||||||
| @@ -360,7 +360,7 @@ public class SubscriptionService( | |||||||
|             Topic = "subscriptions.begun", |             Topic = "subscriptions.begun", | ||||||
|             Title = localizer["SubscriptionAppliedTitle", humanReadableName], |             Title = localizer["SubscriptionAppliedTitle", humanReadableName], | ||||||
|             Body = localizer["SubscriptionAppliedBody", duration, humanReadableName], |             Body = localizer["SubscriptionAppliedBody", duration, humanReadableName], | ||||||
|             IsSavable = false, |             IsSavable = true | ||||||
|         }; |         }; | ||||||
|         notification.Meta.Add("subscription_id", Value.ForString(subscription.Id.ToString())); |         notification.Meta.Add("subscription_id", Value.ForString(subscription.Id.ToString())); | ||||||
|         await pusher.SendPushNotificationToUserAsync( |         await pusher.SendPushNotificationToUserAsync( | ||||||
|   | |||||||
| @@ -4,15 +4,6 @@ using NodaTime.Serialization.SystemTextJson; | |||||||
|  |  | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Pusher.Connection; | ||||||
|  |  | ||||||
| public abstract class WebSocketPacketType |  | ||||||
| { |  | ||||||
|     public const string Error = "error"; |  | ||||||
|     public const string MessageNew = "messages.new"; |  | ||||||
|     public const string MessageUpdate = "messages.update"; |  | ||||||
|     public const string MessageDelete = "messages.delete"; |  | ||||||
|     public const string CallParticipantsUpdate = "call.participants.update"; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class WebSocketPacket | public class WebSocketPacket | ||||||
| { | { | ||||||
|     public string Type { get; set; } = null!; |     public string Type { get; set; } = null!; | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System.Collections.Concurrent; | using System.Collections.Concurrent; | ||||||
| using System.Net.WebSockets; | using System.Net.WebSockets; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
| using DysonNetwork.Shared.Proto; | using DysonNetwork.Shared.Proto; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Pusher.Connection; | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ builder.Services.AddAppServices(builder.Configuration); | |||||||
| builder.Services.AddAppRateLimiting(); | builder.Services.AddAppRateLimiting(); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| builder.Services.AddAppSwagger(); | builder.Services.AddAppSwagger(); | ||||||
| builder.Services.AddDysonAuth(builder.Configuration); | builder.Services.AddDysonAuth(); | ||||||
|  |  | ||||||
| // Add flush handlers and websocket handlers | // Add flush handlers and websocket handlers | ||||||
| builder.Services.AddAppFlushHandlers(); | builder.Services.AddAppFlushHandlers(); | ||||||
|   | |||||||
| @@ -8,8 +8,7 @@ namespace DysonNetwork.Shared.Auth; | |||||||
| public static class DysonAuthStartup | public static class DysonAuthStartup | ||||||
| { | { | ||||||
|     public static IServiceCollection AddDysonAuth( |     public static IServiceCollection AddDysonAuth( | ||||||
|         this IServiceCollection services, |         this IServiceCollection services | ||||||
|         IConfiguration configuration |  | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         services.AddSingleton<AuthService.AuthServiceClient>(sp => |         services.AddSingleton<AuthService.AuthServiceClient>(sp => | ||||||
|   | |||||||
							
								
								
									
										40
									
								
								DysonNetwork.Shared/Content/TextSanitizer.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								DysonNetwork.Shared/Content/TextSanitizer.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | using System.Text; | ||||||
|  | using System.Globalization; | ||||||
|  | using System.Text.RegularExpressions; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Shared.Content; | ||||||
|  |  | ||||||
|  | public abstract partial class TextSanitizer | ||||||
|  | { | ||||||
|  |     [GeneratedRegex(@"[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFFF0-\uFFFF]")] | ||||||
|  |     private static partial Regex WeirdUnicodeRegex(); | ||||||
|  |  | ||||||
|  |     [GeneratedRegex(@"[\r\n]+")] | ||||||
|  |     private static partial Regex NewlineRegex(); | ||||||
|  |  | ||||||
|  |     public static string? Sanitize(string? text) | ||||||
|  |     { | ||||||
|  |         if (text is null) return null; | ||||||
|  |  | ||||||
|  |         // Normalize weird Unicode characters | ||||||
|  |         var cleaned = WeirdUnicodeRegex().Replace(text, ""); | ||||||
|  |  | ||||||
|  |         // Normalize bold/italic/fancy unicode letters to ASCII | ||||||
|  |         cleaned = NormalizeFancyUnicode(cleaned); | ||||||
|  |  | ||||||
|  |         // Replace multiple newlines with a single newline | ||||||
|  |         cleaned = NewlineRegex().Replace(cleaned, "\n"); | ||||||
|  |  | ||||||
|  |         return cleaned; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string NormalizeFancyUnicode(string input) | ||||||
|  |     { | ||||||
|  |         var sb = new StringBuilder(input.Length); | ||||||
|  |         foreach (var c in input.Normalize(NormalizationForm.FormKC).Where(c => | ||||||
|  |                      char.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)) | ||||||
|  |             sb.Append(c); | ||||||
|  |  | ||||||
|  |         return sb.ToString(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								DysonNetwork.Shared/CultureService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								DysonNetwork.Shared/CultureService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | using System.Globalization; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Shared; | ||||||
|  |  | ||||||
|  | public static class CultureService | ||||||
|  | { | ||||||
|  |     public static void SetCultureInfo(string? languageCode) | ||||||
|  |     { | ||||||
|  |         var info = new CultureInfo(languageCode ?? "en-us", false); | ||||||
|  |         CultureInfo.CurrentCulture = info; | ||||||
|  |         CultureInfo.CurrentUICulture = info; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     public static void SetCultureInfo(Account account) | ||||||
|  |     { | ||||||
|  |         SetCultureInfo(account.Language); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,8 +1,4 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | namespace DysonNetwork.Shared.Data; | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using Point = NetTopologySuite.Geometries.Point; |  | ||||||
| 
 |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
| 
 | 
 | ||||||
| public abstract class ActionLogType | public abstract class ActionLogType | ||||||
| { | { | ||||||
| @@ -42,17 +38,3 @@ public abstract class ActionLogType | |||||||
|     public const string ChatroomKick = "chatrooms.kick"; |     public const string ChatroomKick = "chatrooms.kick"; | ||||||
|     public const string ChatroomAdjustRole = "chatrooms.role.edit"; |     public const string ChatroomAdjustRole = "chatrooms.role.edit"; | ||||||
| } | } | ||||||
| 
 |  | ||||||
| public class ActionLog : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(4096)] public string Action { get; set; } = null!; |  | ||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); |  | ||||||
|     [MaxLength(512)] public string? UserAgent { get; set; } |  | ||||||
|     [MaxLength(128)] public string? IpAddress { get; set; } |  | ||||||
|     public Point? Location { get; set; } |  | ||||||
| 
 |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
|     public Guid? SessionId { get; set; } |  | ||||||
| } |  | ||||||
| @@ -3,6 +3,23 @@ using Google.Protobuf.WellKnownTypes; | |||||||
|  |  | ||||||
| namespace DysonNetwork.Shared.Data; | namespace DysonNetwork.Shared.Data; | ||||||
|  |  | ||||||
|  | public enum ContentSensitiveMark | ||||||
|  | { | ||||||
|  |     Language, | ||||||
|  |     SexualContent, | ||||||
|  |     Violence, | ||||||
|  |     Profanity, | ||||||
|  |     HateSpeech, | ||||||
|  |     Racism, | ||||||
|  |     AdultContent, | ||||||
|  |     DrugAbuse, | ||||||
|  |     AlcoholAbuse, | ||||||
|  |     Gambling, | ||||||
|  |     SelfHarm, | ||||||
|  |     ChildAbuse, | ||||||
|  |     Other | ||||||
|  | } | ||||||
|  |  | ||||||
| /// <summary> | /// <summary> | ||||||
| /// The class that used in jsonb columns which referenced the cloud file. | /// The class that used in jsonb columns which referenced the cloud file. | ||||||
| /// The aim of this class is to store some properties that won't change to a file to reduce the database load. | /// The aim of this class is to store some properties that won't change to a file to reduce the database load. | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Shared.Data; | ||||||
| 
 | 
 | ||||||
| /// <summary> | /// <summary> | ||||||
| /// The verification info of a resource | /// The verification info of a resource | ||||||
							
								
								
									
										11
									
								
								DysonNetwork.Shared/Data/WebSocket.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								DysonNetwork.Shared/Data/WebSocket.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | namespace DysonNetwork.Shared.Data; | ||||||
|  |  | ||||||
|  | public abstract class WebSocketPacketType | ||||||
|  | { | ||||||
|  |     public const string Error = "error"; | ||||||
|  |     public const string MessageNew = "messages.new"; | ||||||
|  |     public const string MessageUpdate = "messages.update"; | ||||||
|  |     public const string MessageDelete = "messages.delete"; | ||||||
|  |     public const string CallParticipantsUpdate = "call.participants.update"; | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -73,4 +73,28 @@ public static class GrpcClientHelper | |||||||
|         return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, |         return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|             clientCertPassword)); |             clientCertPassword)); | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     public static async Task<FileService.FileServiceClient> CreateFileServiceClient( | ||||||
|  |         IEtcdClient etcdClient, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.File"); | ||||||
|  |         return new FileService.FileServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     public static async Task<FileReferenceService.FileReferenceServiceClient> CreateFileReferenceServiceClient( | ||||||
|  |         IEtcdClient etcdClient, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.FileReference"); | ||||||
|  |         return new FileReferenceService.FileReferenceServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -86,4 +86,19 @@ public abstract class GrpcTypeHelper | |||||||
|             _ => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value, SerializerSettings)) |             _ => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value, SerializerSettings)) | ||||||
|         }; |         }; | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     public static Value ConvertObjectToValue(object? obj) | ||||||
|  |     { | ||||||
|  |         return obj switch | ||||||
|  |         { | ||||||
|  |             string s => Value.ForString(s), | ||||||
|  |             int i => Value.ForNumber(i), | ||||||
|  |             long l => Value.ForNumber(l), | ||||||
|  |             float f => Value.ForNumber(f), | ||||||
|  |             double d => Value.ForNumber(d), | ||||||
|  |             bool b => Value.ForBool(b), | ||||||
|  |             null => Value.ForNull(), | ||||||
|  |             _ => Value.ForString(JsonConvert.SerializeObject(obj, SerializerSettings)) // fallback to JSON string | ||||||
|  |         }; | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -155,24 +155,15 @@ message BadgeReferenceObject { | |||||||
|  |  | ||||||
| // Relationship represents a connection between two accounts | // Relationship represents a connection between two accounts | ||||||
| message Relationship { | message Relationship { | ||||||
|   string id = 1; |   string account_id = 1; | ||||||
|   string from_account_id = 2; |   string related_id = 2; | ||||||
|   string to_account_id = 3; |   optional Account account = 3; | ||||||
|   RelationshipType type = 4; |   optional Account related = 4; | ||||||
|   string note = 5; |   int32 status = 5; | ||||||
|   google.protobuf.Timestamp created_at = 6; |   google.protobuf.Timestamp created_at = 6; | ||||||
|   google.protobuf.Timestamp updated_at = 7; |   google.protobuf.Timestamp updated_at = 7; | ||||||
| } | } | ||||||
|  |  | ||||||
| // Enum for relationship types |  | ||||||
| enum RelationshipType { |  | ||||||
|   RELATIONSHIP_TYPE_UNSPECIFIED = 0; |  | ||||||
|   FRIEND = 1; |  | ||||||
|   BLOCKED = 2; |  | ||||||
|   PENDING_INCOMING = 3; |  | ||||||
|   PENDING_OUTGOING = 4; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Leveling information | // Leveling information | ||||||
| message LevelingInfo { | message LevelingInfo { | ||||||
|   int32 current_level = 1; |   int32 current_level = 1; | ||||||
| @@ -183,6 +174,19 @@ message LevelingInfo { | |||||||
|   repeated int32 experience_per_level = 6; |   repeated int32 experience_per_level = 6; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // ActionLog represents a record of an action taken by a user | ||||||
|  | message ActionLog { | ||||||
|  |   string id = 1; // Unique identifier for the log entry | ||||||
|  |   string action = 2; // The action that was performed, e.g., "user.login" | ||||||
|  |   map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action | ||||||
|  |   google.protobuf.StringValue user_agent = 4; // User agent of the client | ||||||
|  |   google.protobuf.StringValue ip_address = 5; // IP address of the client | ||||||
|  |   google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP | ||||||
|  |   string account_id = 7; // The account that performed the action | ||||||
|  |   google.protobuf.StringValue session_id = 8; // The session in which the action was performed | ||||||
|  |   google.protobuf.Timestamp created_at = 9; // When the action log was created | ||||||
|  | } | ||||||
|  |  | ||||||
| // ==================================== | // ==================================== | ||||||
| // Service Definitions | // Service Definitions | ||||||
| // ==================================== | // ==================================== | ||||||
| @@ -192,49 +196,71 @@ service AccountService { | |||||||
|   // Account Operations |   // Account Operations | ||||||
|   rpc GetAccount(GetAccountRequest) returns (Account) {} |   rpc GetAccount(GetAccountRequest) returns (Account) {} | ||||||
|   rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {} |   rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {} | ||||||
|   rpc CreateAccount(CreateAccountRequest) returns (Account) {} |  | ||||||
|   rpc UpdateAccount(UpdateAccountRequest) returns (Account) {} |  | ||||||
|   rpc DeleteAccount(DeleteAccountRequest) returns (google.protobuf.Empty) {} |  | ||||||
|   rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {} |   rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {} | ||||||
|  |  | ||||||
|   // Profile Operations |   // Profile Operations | ||||||
|   rpc GetProfile(GetProfileRequest) returns (AccountProfile) {} |   rpc GetProfile(GetProfileRequest) returns (AccountProfile) {} | ||||||
|   rpc UpdateProfile(UpdateProfileRequest) returns (AccountProfile) {} |  | ||||||
|  |  | ||||||
|   // Contact Operations |   // Contact Operations | ||||||
|   rpc AddContact(AddContactRequest) returns (AccountContact) {} |  | ||||||
|   rpc UpdateContact(UpdateContactRequest) returns (AccountContact) {} |  | ||||||
|   rpc RemoveContact(RemoveContactRequest) returns (google.protobuf.Empty) {} |  | ||||||
|   rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {} |   rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {} | ||||||
|   rpc VerifyContact(VerifyContactRequest) returns (AccountContact) {} |  | ||||||
|  |  | ||||||
|   // Badge Operations |   // Badge Operations | ||||||
|   rpc AddBadge(AddBadgeRequest) returns (AccountBadge) {} |  | ||||||
|   rpc RemoveBadge(RemoveBadgeRequest) returns (google.protobuf.Empty) {} |  | ||||||
|   rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {} |   rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {} | ||||||
|   rpc SetActiveBadge(SetActiveBadgeRequest) returns (AccountProfile) {} |  | ||||||
|  |  | ||||||
|   // Authentication Factor Operations |   // Authentication Factor Operations | ||||||
|   rpc AddAuthFactor(AddAuthFactorRequest) returns (AccountAuthFactor) {} |  | ||||||
|   rpc RemoveAuthFactor(RemoveAuthFactorRequest) returns (google.protobuf.Empty) {} |  | ||||||
|   rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {} |   rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {} | ||||||
|  |  | ||||||
|   // Connection Operations |   // Connection Operations | ||||||
|   rpc AddConnection(AddConnectionRequest) returns (AccountConnection) {} |  | ||||||
|   rpc RemoveConnection(RemoveConnectionRequest) returns (google.protobuf.Empty) {} |  | ||||||
|   rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {} |   rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {} | ||||||
|  |  | ||||||
|   // Relationship Operations |   // Relationship Operations | ||||||
|   rpc CreateRelationship(CreateRelationshipRequest) returns (Relationship) {} |  | ||||||
|   rpc UpdateRelationship(UpdateRelationshipRequest) returns (Relationship) {} |  | ||||||
|   rpc DeleteRelationship(DeleteRelationshipRequest) returns (google.protobuf.Empty) {} |  | ||||||
|   rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {} |   rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {} | ||||||
|  |  | ||||||
|  |   rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {} | ||||||
|  |   rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {} | ||||||
|  |   rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {} | ||||||
|  |   rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ActionLogService provides operations for action logs | ||||||
|  | service ActionLogService { | ||||||
|  |   rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {} | ||||||
|  |   rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {} | ||||||
| } | } | ||||||
|  |  | ||||||
| // ==================================== | // ==================================== | ||||||
| // Request/Response Messages | // Request/Response Messages | ||||||
| // ==================================== | // ==================================== | ||||||
|  |  | ||||||
|  | // ActionLog Requests/Responses | ||||||
|  | message CreateActionLogRequest { | ||||||
|  |   string action = 1; | ||||||
|  |   map<string, google.protobuf.Value> meta = 2; | ||||||
|  |   google.protobuf.StringValue user_agent = 3; | ||||||
|  |   google.protobuf.StringValue ip_address = 4; | ||||||
|  |   google.protobuf.StringValue location = 5; | ||||||
|  |   string account_id = 6; | ||||||
|  |   google.protobuf.StringValue session_id = 7; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message CreateActionLogResponse { | ||||||
|  |   ActionLog action_log = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message ListActionLogsRequest { | ||||||
|  |   string account_id = 1; | ||||||
|  |   string action = 2; | ||||||
|  |   int32 page_size = 3; | ||||||
|  |   string page_token = 4; | ||||||
|  |   string order_by = 5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message ListActionLogsResponse { | ||||||
|  |   repeated ActionLog action_logs = 1; | ||||||
|  |   string next_page_token = 2; | ||||||
|  |   int32 total_size = 3; | ||||||
|  | } | ||||||
|  |  | ||||||
| // Account Requests/Responses | // Account Requests/Responses | ||||||
| message GetAccountRequest { | message GetAccountRequest { | ||||||
|   string id = 1;  // Account ID to retrieve |   string id = 1;  // Account ID to retrieve | ||||||
| @@ -301,18 +327,6 @@ message AddContactRequest { | |||||||
|   bool is_primary = 4;  // If this should be the primary contact |   bool is_primary = 4;  // If this should be the primary contact | ||||||
| } | } | ||||||
|  |  | ||||||
| message UpdateContactRequest { |  | ||||||
|   string id = 1;  // Contact ID to update |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
|   google.protobuf.StringValue content = 3;  // New contact content |  | ||||||
|   google.protobuf.BoolValue is_primary = 4;  // New primary status |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message RemoveContactRequest { |  | ||||||
|   string id = 1;  // Contact ID to remove |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message ListContactsRequest { | message ListContactsRequest { | ||||||
|   string account_id = 1;  // Account ID to list contacts for |   string account_id = 1;  // Account ID to list contacts for | ||||||
|   AccountContactType type = 2;  // Optional: filter by type |   AccountContactType type = 2;  // Optional: filter by type | ||||||
| @@ -330,20 +344,6 @@ message VerifyContactRequest { | |||||||
| } | } | ||||||
|  |  | ||||||
| // Badge Requests/Responses | // Badge Requests/Responses | ||||||
| message AddBadgeRequest { |  | ||||||
|   string account_id = 1;  // Account to add badge to |  | ||||||
|   string type = 2;  // Type of badge |  | ||||||
|   google.protobuf.StringValue label = 3;  // Display label |  | ||||||
|   google.protobuf.StringValue caption = 4;  // Description |  | ||||||
|   map<string, string> meta = 5;  // Additional metadata |  | ||||||
|   google.protobuf.Timestamp expired_at = 6;  // Optional expiration |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message RemoveBadgeRequest { |  | ||||||
|   string id = 1;  // Badge ID to remove |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message ListBadgesRequest { | message ListBadgesRequest { | ||||||
|   string account_id = 1;  // Account to list badges for |   string account_id = 1;  // Account to list badges for | ||||||
|   string type = 2;  // Optional: filter by type |   string type = 2;  // Optional: filter by type | ||||||
| @@ -354,26 +354,6 @@ message ListBadgesResponse { | |||||||
|   repeated AccountBadge badges = 1;  // List of badges |   repeated AccountBadge badges = 1;  // List of badges | ||||||
| } | } | ||||||
|  |  | ||||||
| message SetActiveBadgeRequest { |  | ||||||
|   string account_id = 1;  // Account to update |  | ||||||
|   string badge_id = 2;  // Badge ID to set as active (empty to clear) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Authentication Factor Requests/Responses |  | ||||||
| message AddAuthFactorRequest { |  | ||||||
|   string account_id = 1;  // Account to add factor to |  | ||||||
|   AccountAuthFactorType type = 2;  // Type of factor |  | ||||||
|   string secret = 3;  // Factor secret (hashed on server) |  | ||||||
|   map<string, string> config = 4;  // Configuration |  | ||||||
|   int32 trustworthy = 5;  // Trust level |  | ||||||
|   google.protobuf.Timestamp expired_at = 6;  // Optional expiration |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message RemoveAuthFactorRequest { |  | ||||||
|   string id = 1;  // Factor ID to remove |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message ListAuthFactorsRequest { | message ListAuthFactorsRequest { | ||||||
|   string account_id = 1;  // Account to list factors for |   string account_id = 1;  // Account to list factors for | ||||||
|   bool active_only = 2;  // Only return active (non-expired) factors |   bool active_only = 2;  // Only return active (non-expired) factors | ||||||
| @@ -383,21 +363,6 @@ message ListAuthFactorsResponse { | |||||||
|   repeated AccountAuthFactor factors = 1;  // List of auth factors |   repeated AccountAuthFactor factors = 1;  // List of auth factors | ||||||
| } | } | ||||||
|  |  | ||||||
| // Connection Requests/Responses |  | ||||||
| message AddConnectionRequest { |  | ||||||
|   string account_id = 1;  // Account to add connection to |  | ||||||
|   string provider = 2;  // Provider name (e.g., "google", "github") |  | ||||||
|   string provided_identifier = 3;  // Provider's user ID |  | ||||||
|   map<string, string> meta = 4;  // Additional metadata |  | ||||||
|   google.protobuf.StringValue access_token = 5;  // OAuth access token |  | ||||||
|   google.protobuf.StringValue refresh_token = 6;  // OAuth refresh token |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message RemoveConnectionRequest { |  | ||||||
|   string id = 1;  // Connection ID to remove |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message ListConnectionsRequest { | message ListConnectionsRequest { | ||||||
|   string account_id = 1;  // Account to list connections for |   string account_id = 1;  // Account to list connections for | ||||||
|   string provider = 2;  // Optional: filter by provider |   string provider = 2;  // Optional: filter by provider | ||||||
| @@ -408,30 +373,9 @@ message ListConnectionsResponse { | |||||||
| } | } | ||||||
|  |  | ||||||
| // Relationship Requests/Responses | // Relationship Requests/Responses | ||||||
| message CreateRelationshipRequest { |  | ||||||
|   string from_account_id = 1;  // Source account ID |  | ||||||
|   string to_account_id = 2;  // Target account ID |  | ||||||
|   RelationshipType type = 3;  // Type of relationship |  | ||||||
|   string note = 4;  // Optional note |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message UpdateRelationshipRequest { |  | ||||||
|   string id = 1;  // Relationship ID to update |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
|   RelationshipType type = 3;  // New relationship type |  | ||||||
|   google.protobuf.StringValue note = 4;  // New note |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message DeleteRelationshipRequest { |  | ||||||
|   string id = 1;  // Relationship ID to delete |  | ||||||
|   string account_id = 2;  // Account ID (for validation) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| message ListRelationshipsRequest { | message ListRelationshipsRequest { | ||||||
|   string account_id = 1;  // Account to list relationships for |   string account_id = 1;  // Account to list relationships for | ||||||
|   RelationshipType type = 2;  // Optional: filter by type |   optional int32 status = 2;  // Filter by status | ||||||
|   bool incoming = 3;  // If true, list incoming relationships |  | ||||||
|   bool outgoing = 4;  // If true, list outgoing relationships |  | ||||||
|   int32 page_size = 5;  // Number of results per page |   int32 page_size = 5;  // Number of results per page | ||||||
|   string page_token = 6;  // Token for pagination |   string page_token = 6;  // Token for pagination | ||||||
| } | } | ||||||
| @@ -442,3 +386,20 @@ message ListRelationshipsResponse { | |||||||
|   int32 total_size = 3;  // Total number of relationships |   int32 total_size = 3;  // Total number of relationships | ||||||
| } | } | ||||||
|  |  | ||||||
|  | message GetRelationshipRequest { | ||||||
|  |   string account_id = 1; | ||||||
|  |   string related_id = 2; | ||||||
|  |   optional int32 status = 3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message GetRelationshipResponse { | ||||||
|  |   optional Relationship relationship = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message ListRelationshipSimpleRequest { | ||||||
|  |   string account_id = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message ListRelationshipSimpleResponse { | ||||||
|  |   repeated string accounts_id = 1; | ||||||
|  | } | ||||||
| @@ -65,6 +65,9 @@ enum ChallengePlatform { | |||||||
|  |  | ||||||
| service AuthService { | service AuthService { | ||||||
|   rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {} |   rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse) {} | ||||||
|  |    | ||||||
|  |   rpc ValidatePin(ValidatePinRequest) returns (ValidateResponse) {} | ||||||
|  |   rpc ValidateCaptcha(ValidateCaptchaRequest) returns (ValidateResponse) {} | ||||||
| } | } | ||||||
|  |  | ||||||
| message AuthenticateRequest { | message AuthenticateRequest { | ||||||
| @@ -77,6 +80,19 @@ message AuthenticateResponse { | |||||||
|   optional AuthSession session = 3; |   optional AuthSession session = 3; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | message ValidatePinRequest { | ||||||
|  |   string account_id = 1; | ||||||
|  |   string pin = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message ValidateCaptchaRequest { | ||||||
|  |   string token = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message ValidateResponse { | ||||||
|  |   bool valid = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
| // Permission related messages and services | // Permission related messages and services | ||||||
| message PermissionNode { | message PermissionNode { | ||||||
|   string id = 1; |   string id = 1; | ||||||
|   | |||||||
| @@ -51,6 +51,7 @@ message CloudFile { | |||||||
| service FileService { | service FileService { | ||||||
|   // Get file reference by ID |   // Get file reference by ID | ||||||
|   rpc GetFile(GetFileRequest) returns (CloudFile); |   rpc GetFile(GetFileRequest) returns (CloudFile); | ||||||
|  |   rpc GetFileBatch(GetFileBatchRequest) returns (GetFileBatchResponse); | ||||||
|  |  | ||||||
|   // Update an existing file reference |   // Update an existing file reference | ||||||
|   rpc UpdateFile(UpdateFileRequest) returns (CloudFile); |   rpc UpdateFile(UpdateFileRequest) returns (CloudFile); | ||||||
| @@ -73,6 +74,14 @@ message GetFileRequest { | |||||||
|   string id = 1; |   string id = 1; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | message GetFileBatchRequest { | ||||||
|  |   repeated string ids = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message GetFileBatchResponse { | ||||||
|  |   repeated CloudFile files = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
| // Request message for UpdateFile | // Request message for UpdateFile | ||||||
| message UpdateFileRequest { | message UpdateFileRequest { | ||||||
|   CloudFile file = 1; |   CloudFile file = 1; | ||||||
| @@ -157,6 +166,18 @@ message CreateReferenceRequest { | |||||||
|   optional google.protobuf.Duration duration = 5; // Alternative to expired_at |   optional google.protobuf.Duration duration = 5; // Alternative to expired_at | ||||||
| } | } | ||||||
|  |  | ||||||
|  | message CreateReferenceBatchRequest { | ||||||
|  |   repeated string files_id = 1; | ||||||
|  |   string usage = 2; | ||||||
|  |   string resource_id = 3; | ||||||
|  |   optional google.protobuf.Timestamp expired_at = 4; | ||||||
|  |   optional google.protobuf.Duration duration = 5; // Alternative to expired_at | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message CreateReferenceBatchResponse { | ||||||
|  |   repeated CloudFileReference references = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
| message GetReferencesRequest { | message GetReferencesRequest { | ||||||
|   string file_id = 1; |   string file_id = 1; | ||||||
| } | } | ||||||
| @@ -239,6 +260,7 @@ message HasFileReferencesResponse { | |||||||
| service FileReferenceService { | service FileReferenceService { | ||||||
|   // Creates a new reference to a file for a specific resource |   // Creates a new reference to a file for a specific resource | ||||||
|   rpc CreateReference(CreateReferenceRequest) returns (CloudFileReference); |   rpc CreateReference(CreateReferenceRequest) returns (CloudFileReference); | ||||||
|  |   rpc CreateReferenceBatch(CreateReferenceBatchRequest) returns (CreateReferenceBatchResponse); | ||||||
|  |  | ||||||
|   // Gets all references to a file |   // Gets all references to a file | ||||||
|   rpc GetReferences(GetReferencesRequest) returns (GetReferencesResponse); |   rpc GetReferences(GetReferencesRequest) returns (GetReferencesResponse); | ||||||
|   | |||||||
| @@ -44,4 +44,37 @@ public static class ServiceHelper | |||||||
|          |          | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     public static IServiceCollection AddDriveService(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddSingleton<FileService.FileServiceClient>(sp => | ||||||
|  |         { | ||||||
|  |             var etcdClient = sp.GetRequiredService<IEtcdClient>(); | ||||||
|  |             var config = sp.GetRequiredService<IConfiguration>(); | ||||||
|  |             var clientCertPath = config["Service:ClientCert"]!; | ||||||
|  |             var clientKeyPath = config["Service:ClientKey"]!; | ||||||
|  |             var clientCertPassword = config["Service:CertPassword"]; | ||||||
|  |  | ||||||
|  |             return GrpcClientHelper | ||||||
|  |                 .CreateFileServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword) | ||||||
|  |                 .GetAwaiter() | ||||||
|  |                 .GetResult(); | ||||||
|  |         });        | ||||||
|  |          | ||||||
|  |         services.AddSingleton<FileReferenceService.FileReferenceServiceClient>(sp => | ||||||
|  |         { | ||||||
|  |             var etcdClient = sp.GetRequiredService<IEtcdClient>(); | ||||||
|  |             var config = sp.GetRequiredService<IConfiguration>(); | ||||||
|  |             var clientCertPath = config["Service:ClientCert"]!; | ||||||
|  |             var clientKeyPath = config["Service:ClientKey"]!; | ||||||
|  |             var clientCertPassword = config["Service:CertPassword"]; | ||||||
|  |  | ||||||
|  |             return GrpcClientHelper | ||||||
|  |                 .CreateFileReferenceServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword) | ||||||
|  |                 .GetAwaiter() | ||||||
|  |                 .GetResult(); | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,30 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public enum AbuseReportType |  | ||||||
| { |  | ||||||
|     Copyright, |  | ||||||
|     Harassment, |  | ||||||
|     Impersonation, |  | ||||||
|     OffensiveContent, |  | ||||||
|     Spam, |  | ||||||
|     PrivacyViolation, |  | ||||||
|     IllegalContent, |  | ||||||
|     Other |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AbuseReport : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!; |  | ||||||
|     public AbuseReportType Type { get; set; } |  | ||||||
|     [MaxLength(8192)] public string Reason { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public Instant? ResolvedAt { get; set; } |  | ||||||
|     [MaxLength(8192)] public string? Resolution { get; set; } |  | ||||||
|      |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
| @@ -1,196 +0,0 @@ | |||||||
| 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; |  | ||||||
|  |  | ||||||
| [Index(nameof(Name), IsUnique = true)] |  | ||||||
| public class Account : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     [MaxLength(256)] public string Name { get; set; } = string.Empty; |  | ||||||
|     [MaxLength(256)] public string Nick { get; set; } = string.Empty; |  | ||||||
|     [MaxLength(32)] public string Language { get; set; } = string.Empty; |  | ||||||
|     public Instant? ActivatedAt { get; set; } |  | ||||||
|     public bool IsSuperuser { get; set; } = false; |  | ||||||
|  |  | ||||||
|     public Profile Profile { get; set; } = null!; |  | ||||||
|     public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); |  | ||||||
|     public ICollection<Badge> Badges { get; set; } = new List<Badge>(); |  | ||||||
|  |  | ||||||
|     [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<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 abstract class Leveling |  | ||||||
| { |  | ||||||
|     public static readonly List<int> ExperiencePerLevel = |  | ||||||
|     [ |  | ||||||
|         0, // Level 0 |  | ||||||
|         100, // Level 1 |  | ||||||
|         250, // Level 2 |  | ||||||
|         500, // Level 3 |  | ||||||
|         1000, // Level 4 |  | ||||||
|         2000, // Level 5 |  | ||||||
|         4000, // Level 6 |  | ||||||
|         8000, // Level 7 |  | ||||||
|         16000, // Level 8 |  | ||||||
|         32000, // Level 9 |  | ||||||
|         64000, // Level 10 |  | ||||||
|         128000, // Level 11 |  | ||||||
|         256000, // Level 12 |  | ||||||
|         512000, // Level 13 |  | ||||||
|         1024000 // Level 14 |  | ||||||
|     ]; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class Profile : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     [MaxLength(256)] public string? FirstName { get; set; } |  | ||||||
|     [MaxLength(256)] public string? MiddleName { get; set; } |  | ||||||
|     [MaxLength(256)] public string? LastName { get; set; } |  | ||||||
|     [MaxLength(4096)] public string? Bio { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Gender { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Pronouns { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? TimeZone { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Location { get; set; } |  | ||||||
|     public Instant? Birthday { get; set; } |  | ||||||
|     public Instant? LastSeenAt { get; set; } |  | ||||||
|  |  | ||||||
|     [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public SubscriptionReferenceObject? StellarMembership { get; set; } |  | ||||||
|  |  | ||||||
|     public int Experience { get; set; } = 0; |  | ||||||
|     [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; |  | ||||||
|  |  | ||||||
|     [NotMapped] |  | ||||||
|     public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 |  | ||||||
|         ? 100 |  | ||||||
|         : (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 / |  | ||||||
|           (Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]); |  | ||||||
|  |  | ||||||
|     // Outdated fields, for backward compability |  | ||||||
|     [MaxLength(32)] public string? PictureId { get; set; } |  | ||||||
|     [MaxLength(32)] public string? BackgroundId { get; set; } |  | ||||||
|  |  | ||||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AccountContact : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     public AccountContactType Type { get; set; } |  | ||||||
|     public Instant? VerifiedAt { get; set; } |  | ||||||
|     public bool IsPrimary { get; set; } = false; |  | ||||||
|     [MaxLength(1024)] public string Content { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum AccountContactType |  | ||||||
| { |  | ||||||
|     Email, |  | ||||||
|     PhoneNumber, |  | ||||||
|     Address |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AccountAuthFactor : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     public AccountAuthFactorType Type { get; set; } |  | ||||||
|     [JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonIgnore] |  | ||||||
|     [Column(TypeName = "jsonb")] |  | ||||||
|     public Dictionary<string, object>? Config { get; set; } = new(); |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The trustworthy stands for how safe is this auth factor. |  | ||||||
|     /// Basically, it affects how many steps it can complete in authentication. |  | ||||||
|     /// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations. |  | ||||||
|     /// </summary> |  | ||||||
|     public int Trustworthy { get; set; } = 1; |  | ||||||
|  |  | ||||||
|     public Instant? EnabledAt { get; set; } |  | ||||||
|     public Instant? ExpiredAt { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public AccountAuthFactor HashSecret(int cost = 12) |  | ||||||
|     { |  | ||||||
|         if (Secret == null) return this; |  | ||||||
|         Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost); |  | ||||||
|         return this; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public bool VerifyPassword(string password) |  | ||||||
|     { |  | ||||||
|         if (Secret == null) |  | ||||||
|             throw new InvalidOperationException("Auth factor with no secret cannot be verified with password."); |  | ||||||
|         switch (Type) |  | ||||||
|         { |  | ||||||
|             case AccountAuthFactorType.Password: |  | ||||||
|             case AccountAuthFactorType.PinCode: |  | ||||||
|                 return BCrypt.Net.BCrypt.Verify(password, Secret); |  | ||||||
|             case AccountAuthFactorType.TimedCode: |  | ||||||
|                 var otp = new Totp(Base32Encoding.ToBytes(Secret)); |  | ||||||
|                 return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5)); |  | ||||||
|             case AccountAuthFactorType.EmailCode: |  | ||||||
|             case AccountAuthFactorType.InAppCode: |  | ||||||
|             default: |  | ||||||
|                 throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead."); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// This dictionary will be returned to the client and should only be set when it just created. |  | ||||||
|     /// Useful for passing the client some data to finishing setup and recovery code. |  | ||||||
|     /// </summary> |  | ||||||
|     [NotMapped] |  | ||||||
|     public Dictionary<string, object>? CreatedResponse { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum AccountAuthFactorType |  | ||||||
| { |  | ||||||
|     Password, |  | ||||||
|     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!; |  | ||||||
| } |  | ||||||
| @@ -1,177 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using DysonNetwork.Sphere.Auth; |  | ||||||
| using DysonNetwork.Sphere.Permission; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
| using NodaTime.Extensions; |  | ||||||
| using System.Collections.Generic; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/accounts")] |  | ||||||
| public class AccountController( |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     AccountService accounts, |  | ||||||
|     AccountEventService events |  | ||||||
| ) : ControllerBase |  | ||||||
| { |  | ||||||
|     [HttpGet("{name}")] |  | ||||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] |  | ||||||
|     [ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
|     public async Task<ActionResult<Account?>> GetByName(string name) |  | ||||||
|     { |  | ||||||
|         var account = await db.Accounts |  | ||||||
|             .Include(e => e.Badges) |  | ||||||
|             .Include(e => e.Profile) |  | ||||||
|             .Where(a => a.Name == name) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         return account is null ? new NotFoundResult() : account; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("{name}/badges")] |  | ||||||
|     [ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)] |  | ||||||
|     [ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
|     public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name) |  | ||||||
|     { |  | ||||||
|         var account = await db.Accounts |  | ||||||
|             .Include(e => e.Badges) |  | ||||||
|             .Where(a => a.Name == name) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         return account is null ? NotFound() : account.Badges.ToList(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class AccountCreateRequest |  | ||||||
|     { |  | ||||||
|         [Required] |  | ||||||
|         [MinLength(2)] |  | ||||||
|         [MaxLength(256)] |  | ||||||
|         [RegularExpression(@"^[A-Za-z0-9_-]+$", |  | ||||||
|             ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.") |  | ||||||
|         ] |  | ||||||
|         public string Name { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         [Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         [EmailAddress] |  | ||||||
|         [RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")] |  | ||||||
|         [Required] |  | ||||||
|         [MaxLength(1024)] |  | ||||||
|         public string Email { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         [Required] |  | ||||||
|         [MinLength(4)] |  | ||||||
|         [MaxLength(128)] |  | ||||||
|         public string Password { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         [MaxLength(128)] public string Language { get; set; } = "en-us"; |  | ||||||
|  |  | ||||||
|         [Required] public string CaptchaToken { get; set; } = string.Empty; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost] |  | ||||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] |  | ||||||
|     [ProducesResponseType(StatusCodes.Status400BadRequest)] |  | ||||||
|     public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request) |  | ||||||
|     { |  | ||||||
|         if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var account = await accounts.CreateAccount( |  | ||||||
|                 request.Name, |  | ||||||
|                 request.Nick, |  | ||||||
|                 request.Email, |  | ||||||
|                 request.Password, |  | ||||||
|                 request.Language |  | ||||||
|             ); |  | ||||||
|             return Ok(account); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class RecoveryPasswordRequest |  | ||||||
|     { |  | ||||||
|         [Required] public string Account { get; set; } = null!; |  | ||||||
|         [Required] public string CaptchaToken { get; set; } = null!; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("recovery/password")] |  | ||||||
|     public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request) |  | ||||||
|     { |  | ||||||
|         if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); |  | ||||||
|  |  | ||||||
|         var account = await accounts.LookupAccount(request.Account); |  | ||||||
|         if (account is null) return BadRequest("Unable to find the account."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.RequestPasswordReset(account); |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException) |  | ||||||
|         { |  | ||||||
|             return BadRequest("You already requested password reset within 24 hours."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class StatusRequest |  | ||||||
|     { |  | ||||||
|         public StatusAttitude Attitude { get; set; } |  | ||||||
|         public bool IsInvisible { get; set; } |  | ||||||
|         public bool IsNotDisturb { get; set; } |  | ||||||
|         [MaxLength(1024)] public string? Label { get; set; } |  | ||||||
|         public Instant? ClearedAt { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("{name}/statuses")] |  | ||||||
|     public async Task<ActionResult<Status>> GetOtherStatus(string name) |  | ||||||
|     { |  | ||||||
|         var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); |  | ||||||
|         if (account is null) return BadRequest(); |  | ||||||
|         var status = await events.GetStatus(account.Id); |  | ||||||
|         status.IsInvisible = false; // Keep the invisible field not available for other users |  | ||||||
|         return Ok(status); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("{name}/calendar")] |  | ||||||
|     public async Task<ActionResult<List<DailyEventResponse>>> GetOtherEventCalendar( |  | ||||||
|         string name, |  | ||||||
|         [FromQuery] int? month, |  | ||||||
|         [FromQuery] int? year |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; |  | ||||||
|         month ??= currentDate.Month; |  | ||||||
|         year ??= currentDate.Year; |  | ||||||
|  |  | ||||||
|         if (month is < 1 or > 12) return BadRequest("Invalid month."); |  | ||||||
|         if (year < 1) return BadRequest("Invalid year."); |  | ||||||
|  |  | ||||||
|         var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); |  | ||||||
|         if (account is null) return BadRequest(); |  | ||||||
|  |  | ||||||
|         var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true); |  | ||||||
|         return Ok(calendar); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("search")] |  | ||||||
|     public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrWhiteSpace(query)) |  | ||||||
|             return []; |  | ||||||
|         return await db.Accounts |  | ||||||
|             .Include(e => e.Profile) |  | ||||||
|             .Where(a => EF.Functions.ILike(a.Name, $"%{query}%") || |  | ||||||
|                         EF.Functions.ILike(a.Nick, $"%{query}%")) |  | ||||||
|             .Take(take) |  | ||||||
|             .ToListAsync(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,703 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using DysonNetwork.Sphere.Auth; |  | ||||||
| using DysonNetwork.Sphere.Permission; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
| using Org.BouncyCastle.Utilities; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| [Authorize] |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/accounts/me")] |  | ||||||
| public class AccountCurrentController( |  | ||||||
|     AppDatabase db, |  | ||||||
|     AccountService accounts, |  | ||||||
|     FileReferenceService fileRefService, |  | ||||||
|     AccountEventService events, |  | ||||||
|     AuthService auth |  | ||||||
| ) : ControllerBase |  | ||||||
| { |  | ||||||
|     [HttpGet] |  | ||||||
|     [ProducesResponseType<Account>(StatusCodes.Status200OK)] |  | ||||||
|     public async Task<ActionResult<Account>> GetCurrentIdentity() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|         var userId = currentUser.Id; |  | ||||||
|  |  | ||||||
|         var account = await db.Accounts |  | ||||||
|             .Include(e => e.Badges) |  | ||||||
|             .Include(e => e.Profile) |  | ||||||
|             .Where(e => e.Id == userId) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(account); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class BasicInfoRequest |  | ||||||
|     { |  | ||||||
|         [MaxLength(256)] public string? Nick { get; set; } |  | ||||||
|         [MaxLength(32)] public string? Language { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch] |  | ||||||
|     public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); |  | ||||||
|  |  | ||||||
|         if (request.Nick is not null) account.Nick = request.Nick; |  | ||||||
|         if (request.Language is not null) account.Language = request.Language; |  | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|         await accounts.PurgeAccountCache(currentUser); |  | ||||||
|         return currentUser; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class ProfileRequest |  | ||||||
|     { |  | ||||||
|         [MaxLength(256)] public string? FirstName { get; set; } |  | ||||||
|         [MaxLength(256)] public string? MiddleName { get; set; } |  | ||||||
|         [MaxLength(256)] public string? LastName { get; set; } |  | ||||||
|         [MaxLength(1024)] public string? Gender { get; set; } |  | ||||||
|         [MaxLength(1024)] public string? Pronouns { get; set; } |  | ||||||
|         [MaxLength(1024)] public string? TimeZone { get; set; } |  | ||||||
|         [MaxLength(1024)] public string? Location { get; set; } |  | ||||||
|         [MaxLength(4096)] public string? Bio { get; set; } |  | ||||||
|         public Instant? Birthday { get; set; } |  | ||||||
|  |  | ||||||
|         [MaxLength(32)] public string? PictureId { get; set; } |  | ||||||
|         [MaxLength(32)] public string? BackgroundId { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch("profile")] |  | ||||||
|     public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|         var userId = currentUser.Id; |  | ||||||
|  |  | ||||||
|         var profile = await db.AccountProfiles |  | ||||||
|             .Where(p => p.Account.Id == userId) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (profile is null) return BadRequest("Unable to get your account."); |  | ||||||
|  |  | ||||||
|         if (request.FirstName is not null) profile.FirstName = request.FirstName; |  | ||||||
|         if (request.MiddleName is not null) profile.MiddleName = request.MiddleName; |  | ||||||
|         if (request.LastName is not null) profile.LastName = request.LastName; |  | ||||||
|         if (request.Bio is not null) profile.Bio = request.Bio; |  | ||||||
|         if (request.Gender is not null) profile.Gender = request.Gender; |  | ||||||
|         if (request.Pronouns is not null) profile.Pronouns = request.Pronouns; |  | ||||||
|         if (request.Birthday is not null) profile.Birthday = request.Birthday; |  | ||||||
|         if (request.Location is not null) profile.Location = request.Location; |  | ||||||
|         if (request.TimeZone is not null) profile.TimeZone = request.TimeZone; |  | ||||||
|  |  | ||||||
|         if (request.PictureId is not null) |  | ||||||
|         { |  | ||||||
|             var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); |  | ||||||
|             if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); |  | ||||||
|  |  | ||||||
|             var profileResourceId = $"profile:{profile.Id}"; |  | ||||||
|  |  | ||||||
|             // Remove old references for the profile picture |  | ||||||
|             if (profile.Picture is not null) |  | ||||||
|             { |  | ||||||
|                 var oldPictureRefs = |  | ||||||
|                     await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); |  | ||||||
|                 foreach (var oldRef in oldPictureRefs) |  | ||||||
|                 { |  | ||||||
|                     await fileRefService.DeleteReferenceAsync(oldRef.Id); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             profile.Picture = picture.ToReferenceObject(); |  | ||||||
|  |  | ||||||
|             // Create new reference |  | ||||||
|             await fileRefService.CreateReferenceAsync( |  | ||||||
|                 picture.Id, |  | ||||||
|                 "profile.picture", |  | ||||||
|                 profileResourceId |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (request.BackgroundId is not null) |  | ||||||
|         { |  | ||||||
|             var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); |  | ||||||
|             if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); |  | ||||||
|  |  | ||||||
|             var profileResourceId = $"profile:{profile.Id}"; |  | ||||||
|  |  | ||||||
|             // Remove old references for the profile background |  | ||||||
|             if (profile.Background is not null) |  | ||||||
|             { |  | ||||||
|                 var oldBackgroundRefs = |  | ||||||
|                     await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); |  | ||||||
|                 foreach (var oldRef in oldBackgroundRefs) |  | ||||||
|                 { |  | ||||||
|                     await fileRefService.DeleteReferenceAsync(oldRef.Id); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             profile.Background = background.ToReferenceObject(); |  | ||||||
|  |  | ||||||
|             // Create new reference |  | ||||||
|             await fileRefService.CreateReferenceAsync( |  | ||||||
|                 background.Id, |  | ||||||
|                 "profile.background", |  | ||||||
|                 profileResourceId |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         db.Update(profile); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         await accounts.PurgeAccountCache(currentUser); |  | ||||||
|  |  | ||||||
|         return profile; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete] |  | ||||||
|     public async Task<ActionResult> RequestDeleteAccount() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.RequestAccountDeletion(currentUser); |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException) |  | ||||||
|         { |  | ||||||
|             return BadRequest("You already requested account deletion within 24 hours."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("statuses")] |  | ||||||
|     public async Task<ActionResult<Status>> GetCurrentStatus() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|         var status = await events.GetStatus(currentUser.Id); |  | ||||||
|         return Ok(status); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch("statuses")] |  | ||||||
|     [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(); |  | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var status = await db.AccountStatuses |  | ||||||
|             .Where(e => e.AccountId == currentUser.Id) |  | ||||||
|             .Where(e => e.ClearedAt == null || e.ClearedAt > now) |  | ||||||
|             .OrderByDescending(e => e.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (status is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         status.Attitude = request.Attitude; |  | ||||||
|         status.IsInvisible = request.IsInvisible; |  | ||||||
|         status.IsNotDisturb = request.IsNotDisturb; |  | ||||||
|         status.Label = request.Label; |  | ||||||
|         status.ClearedAt = request.ClearedAt; |  | ||||||
|  |  | ||||||
|         db.Update(status); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|         events.PurgeStatusCache(currentUser.Id); |  | ||||||
|  |  | ||||||
|         return status; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("statuses")] |  | ||||||
|     [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(); |  | ||||||
|  |  | ||||||
|         var status = new Status |  | ||||||
|         { |  | ||||||
|             AccountId = currentUser.Id, |  | ||||||
|             Attitude = request.Attitude, |  | ||||||
|             IsInvisible = request.IsInvisible, |  | ||||||
|             IsNotDisturb = request.IsNotDisturb, |  | ||||||
|             Label = request.Label, |  | ||||||
|             ClearedAt = request.ClearedAt |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         return await events.CreateStatus(currentUser, status); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("me/statuses")] |  | ||||||
|     public async Task<ActionResult> DeleteStatus() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var status = await db.AccountStatuses |  | ||||||
|             .Where(s => s.AccountId == currentUser.Id) |  | ||||||
|             .Where(s => s.ClearedAt == null || s.ClearedAt > now) |  | ||||||
|             .OrderByDescending(s => s.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (status is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         await events.ClearStatus(currentUser, status); |  | ||||||
|         return NoContent(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("check-in")] |  | ||||||
|     public async Task<ActionResult<CheckInResult>> GetCheckInResult() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|         var userId = currentUser.Id; |  | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var today = now.InUtc().Date; |  | ||||||
|         var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); |  | ||||||
|         var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); |  | ||||||
|  |  | ||||||
|         var result = await db.AccountCheckInResults |  | ||||||
|             .Where(x => x.AccountId == userId) |  | ||||||
|             .Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay) |  | ||||||
|             .OrderByDescending(x => x.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|         return result is null ? NotFound() : Ok(result); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("check-in")] |  | ||||||
|     public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var isAvailable = await events.CheckInDailyIsAvailable(currentUser); |  | ||||||
|         if (!isAvailable) |  | ||||||
|             return BadRequest("Check-in is not available for today."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser); |  | ||||||
|             return needsCaptcha switch |  | ||||||
|             { |  | ||||||
|                 true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423, |  | ||||||
|                     "Captcha is required for this check-in."), |  | ||||||
|                 true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."), |  | ||||||
|                 _ => await events.CheckInDaily(currentUser) |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("calendar")] |  | ||||||
|     public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month, |  | ||||||
|         [FromQuery] int? year) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; |  | ||||||
|         month ??= currentDate.Month; |  | ||||||
|         year ??= currentDate.Year; |  | ||||||
|  |  | ||||||
|         if (month is < 1 or > 12) return BadRequest("Invalid month."); |  | ||||||
|         if (year < 1) return BadRequest("Invalid year."); |  | ||||||
|  |  | ||||||
|         var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value); |  | ||||||
|         return Ok(calendar); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("actions")] |  | ||||||
|     [ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)] |  | ||||||
|     [ProducesResponseType(StatusCodes.Status401Unauthorized)] |  | ||||||
|     public async Task<ActionResult<List<ActionLog>>> GetActionLogs( |  | ||||||
|         [FromQuery] int take = 20, |  | ||||||
|         [FromQuery] int offset = 0 |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var query = db.ActionLogs |  | ||||||
|             .Where(log => log.AccountId == currentUser.Id) |  | ||||||
|             .OrderByDescending(log => log.CreatedAt); |  | ||||||
|  |  | ||||||
|         var total = await query.CountAsync(); |  | ||||||
|         Response.Headers.Append("X-Total", total.ToString()); |  | ||||||
|  |  | ||||||
|         var logs = await query |  | ||||||
|             .Skip(offset) |  | ||||||
|             .Take(take) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(logs); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("factors")] |  | ||||||
|     public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var factors = await db.AccountAuthFactors |  | ||||||
|             .Include(f => f.Account) |  | ||||||
|             .Where(f => f.Account.Id == currentUser.Id) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(factors); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class AuthFactorRequest |  | ||||||
|     { |  | ||||||
|         public AccountAuthFactorType Type { get; set; } |  | ||||||
|         public string? Secret { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("factors")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|         if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) |  | ||||||
|             return BadRequest($"Auth factor with type {request.Type} is already exists."); |  | ||||||
|  |  | ||||||
|         var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); |  | ||||||
|         return Ok(factor); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("factors/{id:guid}/enable")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var factor = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == currentUser.Id && f.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (factor is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             factor = await accounts.EnableAuthFactor(factor, code); |  | ||||||
|             return Ok(factor); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("factors/{id:guid}/disable")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var factor = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == currentUser.Id && f.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (factor is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             factor = await accounts.DisableAuthFactor(factor); |  | ||||||
|             return Ok(factor); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("factors/{id:guid}")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var factor = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == currentUser.Id && f.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (factor is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.DeleteAuthFactor(factor); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class AuthorizedDevice |  | ||||||
|     { |  | ||||||
|         public string? Label { get; set; } |  | ||||||
|         public string UserAgent { get; set; } = null!; |  | ||||||
|         public string DeviceId { get; set; } = null!; |  | ||||||
|         public ChallengePlatform Platform { get; set; } |  | ||||||
|         public List<Session> Sessions { get; set; } = []; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("devices")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |  | ||||||
|             HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); |  | ||||||
|  |  | ||||||
|         // Group sessions by the related DeviceId, then create an AuthorizedDevice for each group. |  | ||||||
|         var deviceGroups = await db.AuthSessions |  | ||||||
|             .Where(s => s.Account.Id == currentUser.Id) |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .GroupBy(s => s.Challenge.DeviceId!) |  | ||||||
|             .Select(g => new AuthorizedDevice |  | ||||||
|             { |  | ||||||
|                 DeviceId = g.Key!, |  | ||||||
|                 UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!, |  | ||||||
|                 Platform = g.First().Challenge.Platform!, |  | ||||||
|                 Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(), |  | ||||||
|                 Sessions = g |  | ||||||
|                     .OrderByDescending(x => x.LastGrantedAt) |  | ||||||
|                     .ToList() |  | ||||||
|             }) |  | ||||||
|             .ToListAsync(); |  | ||||||
|         deviceGroups = deviceGroups |  | ||||||
|             .OrderByDescending(s => s.Sessions.First().LastGrantedAt) |  | ||||||
|             .ToList(); |  | ||||||
|  |  | ||||||
|         return Ok(deviceGroups); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("sessions")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<List<Session>>> 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(); |  | ||||||
|  |  | ||||||
|         var query = db.AuthSessions |  | ||||||
|             .Include(session => session.Account) |  | ||||||
|             .Include(session => session.Challenge) |  | ||||||
|             .Where(session => session.Account.Id == currentUser.Id); |  | ||||||
|  |  | ||||||
|         var total = await query.CountAsync(); |  | ||||||
|         Response.Headers.Append("X-Total", total.ToString()); |  | ||||||
|         Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); |  | ||||||
|  |  | ||||||
|         var sessions = await query |  | ||||||
|             .OrderByDescending(x => x.LastGrantedAt) |  | ||||||
|             .Skip(offset) |  | ||||||
|             .Take(take) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(sessions); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("sessions/{id:guid}")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Session>> DeleteSession(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.DeleteSession(currentUser, id); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("sessions/current")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Session>> DeleteCurrentSession() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |  | ||||||
|             HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.DeleteSession(currentUser, currentSession.Id); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch("sessions/{id:guid}/label")] |  | ||||||
|     public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.UpdateSessionLabel(currentUser, id, label); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch("sessions/current/label")] |  | ||||||
|     public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser || |  | ||||||
|             HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("contacts")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<List<AccountContact>>> GetContacts() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var contacts = await db.AccountContacts |  | ||||||
|             .Where(c => c.AccountId == currentUser.Id) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(contacts); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class AccountContactRequest |  | ||||||
|     { |  | ||||||
|         [Required] public AccountContactType Type { get; set; } |  | ||||||
|         [Required] public string Content { get; set; } = null!; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("contacts")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content); |  | ||||||
|             return Ok(contact); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("contacts/{id:guid}/verify")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountContact>> VerifyContact(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |  | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (contact is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.VerifyContactMethod(currentUser, contact); |  | ||||||
|             return Ok(contact); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("contacts/{id:guid}/primary")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |  | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (contact is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             contact = await accounts.SetContactMethodPrimary(currentUser, contact); |  | ||||||
|             return Ok(contact); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("contacts/{id:guid}")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<AccountContact>> DeleteContact(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |  | ||||||
|             .Where(c => c.AccountId == currentUser.Id && c.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (contact is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.DeleteContactMethod(currentUser, contact); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("badges")] |  | ||||||
|     [ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<List<Badge>>> GetBadges() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var badges = await db.Badges |  | ||||||
|             .Where(b => b.AccountId == currentUser.Id) |  | ||||||
|             .ToListAsync(); |  | ||||||
|         return Ok(badges); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("badges/{id:guid}/active")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Badge>> ActivateBadge(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.ActiveBadge(currentUser, id); |  | ||||||
|             return Ok(); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,339 +0,0 @@ | |||||||
| using System.Globalization; |  | ||||||
| using DysonNetwork.Sphere.Activity; |  | ||||||
| using DysonNetwork.Sphere.Connection; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using DysonNetwork.Sphere.Wallet; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.Extensions.Caching.Distributed; |  | ||||||
| using Microsoft.Extensions.Localization; |  | ||||||
| using NodaTime; |  | ||||||
| using Org.BouncyCastle.Asn1.X509; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class AccountEventService( |  | ||||||
|     AppDatabase db, |  | ||||||
|     WebSocketService ws, |  | ||||||
|     ICacheService cache, |  | ||||||
|     PaymentService payment, |  | ||||||
|     IStringLocalizer<Localization.AccountEventResource> localizer |  | ||||||
| ) |  | ||||||
| { |  | ||||||
|     private static readonly Random Random = new(); |  | ||||||
|     private const string StatusCacheKey = "AccountStatus_"; |  | ||||||
|  |  | ||||||
|     public void PurgeStatusCache(Guid userId) |  | ||||||
|     { |  | ||||||
|         var cacheKey = $"{StatusCacheKey}{userId}"; |  | ||||||
|         cache.RemoveAsync(cacheKey); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Status> GetStatus(Guid userId) |  | ||||||
|     { |  | ||||||
|         var cacheKey = $"{StatusCacheKey}{userId}"; |  | ||||||
|         var cachedStatus = await cache.GetAsync<Status>(cacheKey); |  | ||||||
|         if (cachedStatus is not null) |  | ||||||
|         { |  | ||||||
|             cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); |  | ||||||
|             return cachedStatus; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var status = await db.AccountStatuses |  | ||||||
|             .Where(e => e.AccountId == userId) |  | ||||||
|             .Where(e => e.ClearedAt == null || e.ClearedAt > now) |  | ||||||
|             .OrderByDescending(e => e.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         var isOnline = ws.GetAccountIsConnected(userId); |  | ||||||
|         if (status is not null) |  | ||||||
|         { |  | ||||||
|             status.IsOnline = !status.IsInvisible && isOnline; |  | ||||||
|             await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"], |  | ||||||
|                 TimeSpan.FromMinutes(5)); |  | ||||||
|             return status; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (isOnline) |  | ||||||
|         { |  | ||||||
|             return new Status |  | ||||||
|             { |  | ||||||
|                 Attitude = StatusAttitude.Neutral, |  | ||||||
|                 IsOnline = true, |  | ||||||
|                 IsCustomized = false, |  | ||||||
|                 Label = "Online", |  | ||||||
|                 AccountId = userId, |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|                 return new Status |  | ||||||
|         { |  | ||||||
|             Attitude = StatusAttitude.Neutral, |  | ||||||
|             IsOnline = false, |  | ||||||
|             IsCustomized = false, |  | ||||||
|             Label = "Offline", |  | ||||||
|             AccountId = userId, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds) |  | ||||||
|     { |  | ||||||
|         var results = new Dictionary<Guid, Status>(); |  | ||||||
|         var cacheMissUserIds = new List<Guid>(); |  | ||||||
|  |  | ||||||
|         foreach (var userId in userIds) |  | ||||||
|         { |  | ||||||
|             var cacheKey = $"{StatusCacheKey}{userId}"; |  | ||||||
|             var cachedStatus = await cache.GetAsync<Status>(cacheKey); |  | ||||||
|             if (cachedStatus != null) |  | ||||||
|             { |  | ||||||
|                 cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); |  | ||||||
|                 results[userId] = cachedStatus; |  | ||||||
|             } |  | ||||||
|             else |  | ||||||
|             { |  | ||||||
|                 cacheMissUserIds.Add(userId); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (cacheMissUserIds.Any()) |  | ||||||
|         { |  | ||||||
|             var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|             var statusesFromDb = await db.AccountStatuses |  | ||||||
|                 .Where(e => cacheMissUserIds.Contains(e.AccountId)) |  | ||||||
|                 .Where(e => e.ClearedAt == null || e.ClearedAt > now) |  | ||||||
|                 .GroupBy(e => e.AccountId) |  | ||||||
|                 .Select(g => g.OrderByDescending(e => e.CreatedAt).First()) |  | ||||||
|                 .ToListAsync(); |  | ||||||
|  |  | ||||||
|             var foundUserIds = new HashSet<Guid>(); |  | ||||||
|  |  | ||||||
|             foreach (var status in statusesFromDb) |  | ||||||
|             { |  | ||||||
|                 var isOnline = ws.GetAccountIsConnected(status.AccountId); |  | ||||||
|                 status.IsOnline = !status.IsInvisible && isOnline; |  | ||||||
|                 results[status.AccountId] = status; |  | ||||||
|                 var cacheKey = $"{StatusCacheKey}{status.AccountId}"; |  | ||||||
|                 await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5)); |  | ||||||
|                 foundUserIds.Add(status.AccountId); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList(); |  | ||||||
|             if (usersWithoutStatus.Any()) |  | ||||||
|             { |  | ||||||
|                 foreach (var userId in usersWithoutStatus) |  | ||||||
|                 { |  | ||||||
|                     var isOnline = ws.GetAccountIsConnected(userId); |  | ||||||
|                     var defaultStatus = new Status |  | ||||||
|                     { |  | ||||||
|                         Attitude = StatusAttitude.Neutral, |  | ||||||
|                         IsOnline = isOnline, |  | ||||||
|                         IsCustomized = false, |  | ||||||
|                         Label = isOnline ? "Online" : "Offline", |  | ||||||
|                         AccountId = userId, |  | ||||||
|                     }; |  | ||||||
|                     results[userId] = defaultStatus; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return results; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Status> CreateStatus(Account user, Status status) |  | ||||||
|     { |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         await db.AccountStatuses |  | ||||||
|             .Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now)) |  | ||||||
|             .ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now)); |  | ||||||
|  |  | ||||||
|         db.AccountStatuses.Add(status); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return status; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task ClearStatus(Account user, Status status) |  | ||||||
|     { |  | ||||||
|         status.ClearedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         db.Update(status); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|         PurgeStatusCache(user.Id); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative) |  | ||||||
|     private const string CaptchaCacheKey = "CheckInCaptcha_"; |  | ||||||
|     private const int CaptchaProbabilityPercent = 20; |  | ||||||
|  |  | ||||||
|     public async Task<bool> CheckInDailyDoAskCaptcha(Account user) |  | ||||||
|     { |  | ||||||
|         var cacheKey = $"{CaptchaCacheKey}{user.Id}"; |  | ||||||
|         var needsCaptcha = await cache.GetAsync<bool?>(cacheKey); |  | ||||||
|         if (needsCaptcha is not null) |  | ||||||
|             return needsCaptcha!.Value; |  | ||||||
|  |  | ||||||
|         var result = Random.Next(100) < CaptchaProbabilityPercent; |  | ||||||
|         await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24)); |  | ||||||
|         return result; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> CheckInDailyIsAvailable(Account user) |  | ||||||
|     { |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var lastCheckIn = await db.AccountCheckInResults |  | ||||||
|             .Where(x => x.AccountId == user.Id) |  | ||||||
|             .OrderByDescending(x => x.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|         if (lastCheckIn == null) |  | ||||||
|             return true; |  | ||||||
|  |  | ||||||
|         var lastDate = lastCheckIn.CreatedAt.InUtc().Date; |  | ||||||
|         var currentDate = now.InUtc().Date; |  | ||||||
|  |  | ||||||
|         return lastDate < currentDate; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public const string CheckInLockKey = "CheckInLock_"; |  | ||||||
|  |  | ||||||
|     public async Task<CheckInResult> CheckInDaily(Account user) |  | ||||||
|     { |  | ||||||
|         var lockKey = $"{CheckInLockKey}{user.Id}"; |  | ||||||
|          |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100)); |  | ||||||
|              |  | ||||||
|             if (lk != null) |  | ||||||
|                 await lk.ReleaseAsync(); |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             // Ignore errors from this pre-check |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Now try to acquire the lock properly |  | ||||||
|         await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); |  | ||||||
|         if (lockObj is null) throw new InvalidOperationException("Check-in was in progress."); |  | ||||||
|  |  | ||||||
|         var cultureInfo = new CultureInfo(user.Language, false); |  | ||||||
|         CultureInfo.CurrentCulture = cultureInfo; |  | ||||||
|         CultureInfo.CurrentUICulture = cultureInfo; |  | ||||||
|  |  | ||||||
|         // Generate 2 positive tips |  | ||||||
|         var positiveIndices = Enumerable.Range(1, FortuneTipCount) |  | ||||||
|             .OrderBy(_ => Random.Next()) |  | ||||||
|             .Take(2) |  | ||||||
|             .ToList(); |  | ||||||
|         var tips = positiveIndices.Select(index => new FortuneTip |  | ||||||
|         { |  | ||||||
|             IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value, |  | ||||||
|             Content = localizer[$"FortuneTipPositiveContent_{index}"].Value |  | ||||||
|         }).ToList(); |  | ||||||
|  |  | ||||||
|         // Generate 2 negative tips |  | ||||||
|         var negativeIndices = Enumerable.Range(1, FortuneTipCount) |  | ||||||
|             .Except(positiveIndices) |  | ||||||
|             .OrderBy(_ => Random.Next()) |  | ||||||
|             .Take(2) |  | ||||||
|             .ToList(); |  | ||||||
|         tips.AddRange(negativeIndices.Select(index => new FortuneTip |  | ||||||
|         { |  | ||||||
|             IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value, |  | ||||||
|             Content = localizer[$"FortuneTipNegativeContent_{index}"].Value |  | ||||||
|         })); |  | ||||||
|  |  | ||||||
|         var result = new CheckInResult |  | ||||||
|         { |  | ||||||
|             Tips = tips, |  | ||||||
|             Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length), |  | ||||||
|             AccountId = user.Id, |  | ||||||
|             RewardExperience = 100, |  | ||||||
|             RewardPoints = 10, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date; |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             if (result.RewardPoints.HasValue) |  | ||||||
|                 await payment.CreateTransactionWithAccountAsync( |  | ||||||
|                     null, |  | ||||||
|                     user.Id, |  | ||||||
|                     WalletCurrency.SourcePoint, |  | ||||||
|                     result.RewardPoints.Value, |  | ||||||
|                     $"Check-in reward on {now:yyyy/MM/dd}" |  | ||||||
|                 ); |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             result.RewardPoints = null; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         await db.AccountProfiles |  | ||||||
|             .Where(p => p.AccountId == user.Id) |  | ||||||
|             .ExecuteUpdateAsync(s => |  | ||||||
|                 s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience) |  | ||||||
|             ); |  | ||||||
|         db.AccountCheckInResults.Add(result); |  | ||||||
|         await db.SaveChangesAsync();  // Don't forget to save changes to the database |  | ||||||
|  |  | ||||||
|         // The lock will be automatically released by the await using statement |  | ||||||
|         return result; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0, |  | ||||||
|         bool replaceInvisible = false) |  | ||||||
|     { |  | ||||||
|         if (year == 0) |  | ||||||
|             year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year; |  | ||||||
|  |  | ||||||
|         // Create start and end dates for the specified month |  | ||||||
|         var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); |  | ||||||
|         var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month))); |  | ||||||
|  |  | ||||||
|         var statuses = await db.AccountStatuses |  | ||||||
|             .AsNoTracking() |  | ||||||
|             .TagWith("GetEventCalendar_Statuses") |  | ||||||
|             .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) |  | ||||||
|             .Select(x => new Status |  | ||||||
|             { |  | ||||||
|                 Id = x.Id, |  | ||||||
|                 Attitude = x.Attitude, |  | ||||||
|                 IsInvisible = !replaceInvisible && x.IsInvisible, |  | ||||||
|                 IsNotDisturb = x.IsNotDisturb, |  | ||||||
|                 Label = x.Label, |  | ||||||
|                 ClearedAt = x.ClearedAt, |  | ||||||
|                 AccountId = x.AccountId, |  | ||||||
|                 CreatedAt = x.CreatedAt |  | ||||||
|             }) |  | ||||||
|             .OrderBy(x => x.CreatedAt) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         var checkIn = await db.AccountCheckInResults |  | ||||||
|             .AsNoTracking() |  | ||||||
|             .TagWith("GetEventCalendar_CheckIn") |  | ||||||
|             .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month)) |  | ||||||
|             .Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant()) |  | ||||||
|             .ToList(); |  | ||||||
|  |  | ||||||
|         var statusesByDate = statuses |  | ||||||
|             .GroupBy(s => s.CreatedAt.InUtc().Date) |  | ||||||
|             .ToDictionary(g => g.Key, g => g.ToList()); |  | ||||||
|  |  | ||||||
|         var checkInByDate = checkIn |  | ||||||
|             .ToDictionary(c => c.CreatedAt.InUtc().Date); |  | ||||||
|  |  | ||||||
|         return dates.Select(date => |  | ||||||
|         { |  | ||||||
|             var utcDate = date.InUtc().Date; |  | ||||||
|             return new DailyEventResponse |  | ||||||
|             { |  | ||||||
|                 Date = date, |  | ||||||
|                 CheckInResult = checkInByDate.GetValueOrDefault(utcDate), |  | ||||||
|                 Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>()) |  | ||||||
|             }; |  | ||||||
|         }).ToList(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,657 +0,0 @@ | |||||||
| using System.Globalization; |  | ||||||
| using DysonNetwork.Sphere.Auth; |  | ||||||
| using DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
| using DysonNetwork.Sphere.Email; |  | ||||||
|  |  | ||||||
| using DysonNetwork.Sphere.Localization; |  | ||||||
| using DysonNetwork.Sphere.Permission; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using EFCore.BulkExtensions; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.Extensions.Localization; |  | ||||||
| using NodaTime; |  | ||||||
| using Org.BouncyCastle.Utilities; |  | ||||||
| using OtpNet; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class AccountService( |  | ||||||
|     AppDatabase db, |  | ||||||
|     MagicSpellService spells, |  | ||||||
|     AccountUsernameService uname, |  | ||||||
|     NotificationService nty, |  | ||||||
|     EmailService mailer, |  | ||||||
|     IStringLocalizer<NotificationResource> localizer, |  | ||||||
|     ICacheService cache, |  | ||||||
|     ILogger<AccountService> logger |  | ||||||
| ) |  | ||||||
| { |  | ||||||
|     public static void SetCultureInfo(Account account) |  | ||||||
|     { |  | ||||||
|         SetCultureInfo(account.Language); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public static void SetCultureInfo(string? languageCode) |  | ||||||
|     { |  | ||||||
|         var info = new CultureInfo(languageCode ?? "en-us", false); |  | ||||||
|         CultureInfo.CurrentCulture = info; |  | ||||||
|         CultureInfo.CurrentUICulture = info; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public const string AccountCachePrefix = "account:"; |  | ||||||
|  |  | ||||||
|     public async Task PurgeAccountCache(Account account) |  | ||||||
|     { |  | ||||||
|         await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Account?> LookupAccount(string probe) |  | ||||||
|     { |  | ||||||
|         var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); |  | ||||||
|         if (account is not null) return account; |  | ||||||
|  |  | ||||||
|         var contact = await db.AccountContacts |  | ||||||
|             .Where(c => c.Content == probe) |  | ||||||
|             .Include(c => c.Account) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         return contact?.Account; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Account?> LookupAccountByConnection(string identifier, string provider) |  | ||||||
|     { |  | ||||||
|         var connection = await db.AccountConnections |  | ||||||
|             .Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider) |  | ||||||
|             .Include(c => c.Account) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         return connection?.Account; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<int?> GetAccountLevel(Guid accountId) |  | ||||||
|     { |  | ||||||
|         var profile = await db.AccountProfiles |  | ||||||
|             .Where(a => a.AccountId == accountId) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         return profile?.Level; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Account> CreateAccount( |  | ||||||
|         string name, |  | ||||||
|         string nick, |  | ||||||
|         string email, |  | ||||||
|         string? password, |  | ||||||
|         string language = "en-US", |  | ||||||
|         bool isEmailVerified = false, |  | ||||||
|         bool isActivated = false |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         await using var transaction = await db.Database.BeginTransactionAsync(); |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync(); |  | ||||||
|             if (dupeNameCount > 0) |  | ||||||
|                 throw new InvalidOperationException("Account name has already been taken."); |  | ||||||
|  |  | ||||||
|             var account = new Account |  | ||||||
|             { |  | ||||||
|                 Name = name, |  | ||||||
|                 Nick = nick, |  | ||||||
|                 Language = language, |  | ||||||
|                 Contacts = new List<AccountContact> |  | ||||||
|                 { |  | ||||||
|                     new() |  | ||||||
|                     { |  | ||||||
|                         Type = AccountContactType.Email, |  | ||||||
|                         Content = email, |  | ||||||
|                         VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null, |  | ||||||
|                         IsPrimary = true |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 AuthFactors = password is not null |  | ||||||
|                     ? new List<AccountAuthFactor> |  | ||||||
|                     { |  | ||||||
|                         new AccountAuthFactor |  | ||||||
|                         { |  | ||||||
|                             Type = AccountAuthFactorType.Password, |  | ||||||
|                             Secret = password, |  | ||||||
|                             EnabledAt = SystemClock.Instance.GetCurrentInstant() |  | ||||||
|                         }.HashSecret() |  | ||||||
|                     } |  | ||||||
|                     : [], |  | ||||||
|                 Profile = new Profile() |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             if (isActivated) |  | ||||||
|             { |  | ||||||
|                 account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                 var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); |  | ||||||
|                 if (defaultGroup is not null) |  | ||||||
|                 { |  | ||||||
|                     db.PermissionGroupMembers.Add(new PermissionGroupMember |  | ||||||
|                     { |  | ||||||
|                         Actor = $"user:{account.Id}", |  | ||||||
|                         Group = defaultGroup |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             else |  | ||||||
|             { |  | ||||||
|                 var spell = await spells.CreateMagicSpell( |  | ||||||
|                     account, |  | ||||||
|                     MagicSpellType.AccountActivation, |  | ||||||
|                     new Dictionary<string, object> |  | ||||||
|                     { |  | ||||||
|                         { "contact_method", account.Contacts.First().Content } |  | ||||||
|                     } |  | ||||||
|                 ); |  | ||||||
|                 await spells.NotifyMagicSpell(spell, true); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             db.Accounts.Add(account); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|             await transaction.CommitAsync(); |  | ||||||
|             return account; |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             await transaction.RollbackAsync(); |  | ||||||
|             throw; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Account> CreateAccount(OidcUserInfo userInfo) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(userInfo.Email)) |  | ||||||
|             throw new ArgumentException("Email is required for account creation"); |  | ||||||
|  |  | ||||||
|         var displayName = !string.IsNullOrEmpty(userInfo.DisplayName) |  | ||||||
|             ? userInfo.DisplayName |  | ||||||
|             : $"{userInfo.FirstName} {userInfo.LastName}".Trim(); |  | ||||||
|  |  | ||||||
|         // Generate username from email |  | ||||||
|         var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email); |  | ||||||
|  |  | ||||||
|         return await CreateAccount( |  | ||||||
|             username, |  | ||||||
|             displayName, |  | ||||||
|             userInfo.Email, |  | ||||||
|             null, |  | ||||||
|             "en-US", |  | ||||||
|             userInfo.EmailVerified, |  | ||||||
|             userInfo.EmailVerified |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task RequestAccountDeletion(Account account) |  | ||||||
|     { |  | ||||||
|         var spell = await spells.CreateMagicSpell( |  | ||||||
|             account, |  | ||||||
|             MagicSpellType.AccountRemoval, |  | ||||||
|             new Dictionary<string, object>(), |  | ||||||
|             SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), |  | ||||||
|             preventRepeat: true |  | ||||||
|         ); |  | ||||||
|         await spells.NotifyMagicSpell(spell); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task RequestPasswordReset(Account account) |  | ||||||
|     { |  | ||||||
|         var spell = await spells.CreateMagicSpell( |  | ||||||
|             account, |  | ||||||
|             MagicSpellType.AuthPasswordReset, |  | ||||||
|             new Dictionary<string, object>(), |  | ||||||
|             SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), |  | ||||||
|             preventRepeat: true |  | ||||||
|         ); |  | ||||||
|         await spells.NotifyMagicSpell(spell); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> CheckAuthFactorExists(Account account, AccountAuthFactorType type) |  | ||||||
|     { |  | ||||||
|         var isExists = await db.AccountAuthFactors |  | ||||||
|             .Where(x => x.AccountId == account.Id && x.Type == type) |  | ||||||
|             .AnyAsync(); |  | ||||||
|         return isExists; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<AccountAuthFactor?> CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret) |  | ||||||
|     { |  | ||||||
|         AccountAuthFactor? factor = null; |  | ||||||
|         switch (type) |  | ||||||
|         { |  | ||||||
|             case AccountAuthFactorType.Password: |  | ||||||
|                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); |  | ||||||
|                 factor = new AccountAuthFactor |  | ||||||
|                 { |  | ||||||
|                     Type = AccountAuthFactorType.Password, |  | ||||||
|                     Trustworthy = 1, |  | ||||||
|                     AccountId = account.Id, |  | ||||||
|                     Secret = secret, |  | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 }.HashSecret(); |  | ||||||
|                 break; |  | ||||||
|             case AccountAuthFactorType.EmailCode: |  | ||||||
|                 factor = new AccountAuthFactor |  | ||||||
|                 { |  | ||||||
|                     Type = AccountAuthFactorType.EmailCode, |  | ||||||
|                     Trustworthy = 2, |  | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 }; |  | ||||||
|                 break; |  | ||||||
|             case AccountAuthFactorType.InAppCode: |  | ||||||
|                 factor = new AccountAuthFactor |  | ||||||
|                 { |  | ||||||
|                     Type = AccountAuthFactorType.InAppCode, |  | ||||||
|                     Trustworthy = 1, |  | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant() |  | ||||||
|                 }; |  | ||||||
|                 break; |  | ||||||
|             case AccountAuthFactorType.TimedCode: |  | ||||||
|                 var skOtp = KeyGeneration.GenerateRandomKey(20); |  | ||||||
|                 var skOtp32 = Base32Encoding.ToString(skOtp); |  | ||||||
|                 factor = new AccountAuthFactor |  | ||||||
|                 { |  | ||||||
|                     Secret = skOtp32, |  | ||||||
|                     Type = AccountAuthFactorType.TimedCode, |  | ||||||
|                     Trustworthy = 2, |  | ||||||
|                     EnabledAt = null, // It needs to be tired once to enable |  | ||||||
|                     CreatedResponse = new Dictionary<string, object> |  | ||||||
|                     { |  | ||||||
|                         ["uri"] = new OtpUri( |  | ||||||
|                             OtpType.Totp, |  | ||||||
|                             skOtp32, |  | ||||||
|                             account.Id.ToString(), |  | ||||||
|                             "Solar Network" |  | ||||||
|                         ).ToString(), |  | ||||||
|                     } |  | ||||||
|                 }; |  | ||||||
|                 break; |  | ||||||
|             case AccountAuthFactorType.PinCode: |  | ||||||
|                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); |  | ||||||
|                 if (!secret.All(char.IsDigit) || secret.Length != 6) |  | ||||||
|                     throw new ArgumentException("PIN code must be exactly 6 digits"); |  | ||||||
|                 factor = new AccountAuthFactor |  | ||||||
|                 { |  | ||||||
|                     Type = AccountAuthFactorType.PinCode, |  | ||||||
|                     Trustworthy = 0, // Only for confirming, can't be used for login |  | ||||||
|                     Secret = secret, |  | ||||||
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 }.HashSecret(); |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 throw new ArgumentOutOfRangeException(nameof(type), type, null); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (factor is null) throw new InvalidOperationException("Unable to create auth factor."); |  | ||||||
|         factor.AccountId = account.Id; |  | ||||||
|         db.AccountAuthFactors.Add(factor); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|         return factor; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code) |  | ||||||
|     { |  | ||||||
|         if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); |  | ||||||
|         if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode) |  | ||||||
|         { |  | ||||||
|             if (code is null || !factor.VerifyPassword(code)) |  | ||||||
|                 throw new InvalidOperationException( |  | ||||||
|                     "Invalid code, you need to enter the correct code to enable the factor." |  | ||||||
|                 ); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         factor.EnabledAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         db.Update(factor); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return factor; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor) |  | ||||||
|     { |  | ||||||
|         if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled."); |  | ||||||
|  |  | ||||||
|         var count = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null) |  | ||||||
|             .CountAsync(); |  | ||||||
|         if (count <= 1) |  | ||||||
|             throw new InvalidOperationException( |  | ||||||
|                 "Disabling this auth factor will cause you have no active auth factors."); |  | ||||||
|  |  | ||||||
|         factor.EnabledAt = null; |  | ||||||
|         db.Update(factor); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return factor; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task DeleteAuthFactor(AccountAuthFactor factor) |  | ||||||
|     { |  | ||||||
|         var count = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == factor.AccountId) |  | ||||||
|             .If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null)) |  | ||||||
|             .CountAsync(); |  | ||||||
|         if (count <= 1) |  | ||||||
|             throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor."); |  | ||||||
|  |  | ||||||
|         db.AccountAuthFactors.Remove(factor); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Send the auth factor verification code to users, for factors like in-app code and email. |  | ||||||
|     /// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account. |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="account">The owner of the auth factor</param> |  | ||||||
|     /// <param name="factor">The auth factor needed to send code</param> |  | ||||||
|     /// <param name="hint">The part of the contact method for verification</param> |  | ||||||
|     public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null) |  | ||||||
|     { |  | ||||||
|         var code = new Random().Next(100000, 999999).ToString("000000"); |  | ||||||
|  |  | ||||||
|         switch (factor.Type) |  | ||||||
|         { |  | ||||||
|             case AccountAuthFactorType.InAppCode: |  | ||||||
|                 if (await _GetFactorCode(factor) is not null) |  | ||||||
|                     throw new InvalidOperationException("A factor code has been sent and in active duration."); |  | ||||||
|  |  | ||||||
|                 await nty.SendNotification( |  | ||||||
|                     account, |  | ||||||
|                     "auth.verification", |  | ||||||
|                     localizer["AuthCodeTitle"], |  | ||||||
|                     null, |  | ||||||
|                     localizer["AuthCodeBody", code], |  | ||||||
|                     save: true |  | ||||||
|                 ); |  | ||||||
|                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); |  | ||||||
|                 break; |  | ||||||
|             case AccountAuthFactorType.EmailCode: |  | ||||||
|                 if (await _GetFactorCode(factor) is not null) |  | ||||||
|                     throw new InvalidOperationException("A factor code has been sent and in active duration."); |  | ||||||
|  |  | ||||||
|                 ArgumentNullException.ThrowIfNull(hint); |  | ||||||
|                 hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", ""); |  | ||||||
|                 if (string.IsNullOrWhiteSpace(hint)) |  | ||||||
|                 { |  | ||||||
|                     logger.LogWarning( |  | ||||||
|                         "Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...", |  | ||||||
|                         factor.Id, |  | ||||||
|                         hint |  | ||||||
|                     ); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var contact = await db.AccountContacts |  | ||||||
|                     .Where(c => c.Type == AccountContactType.Email) |  | ||||||
|                     .Where(c => c.VerifiedAt != null) |  | ||||||
|                     .Where(c => EF.Functions.ILike(c.Content, $"%{hint}%")) |  | ||||||
|                     .Include(c => c.Account) |  | ||||||
|                     .FirstOrDefaultAsync(); |  | ||||||
|                 if (contact is null) |  | ||||||
|                 { |  | ||||||
|                     logger.LogWarning( |  | ||||||
|                         "Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...", |  | ||||||
|                         factor.Id, |  | ||||||
|                         hint |  | ||||||
|                     ); |  | ||||||
|                     return; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                                 await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>( |  | ||||||
|                     account.Nick, |  | ||||||
|                     contact.Content, |  | ||||||
|                     localizer["VerificationEmail"], |  | ||||||
|                     new VerificationEmailModel |  | ||||||
|                     { |  | ||||||
|                         Name = account.Name, |  | ||||||
|                         Code = code |  | ||||||
|                     } |  | ||||||
|                 ); |  | ||||||
|  |  | ||||||
|                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); |  | ||||||
|                 break; |  | ||||||
|             case AccountAuthFactorType.Password: |  | ||||||
|             case AccountAuthFactorType.TimedCode: |  | ||||||
|             default: |  | ||||||
|                 // No need to send, such as password etc... |  | ||||||
|                 return; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code) |  | ||||||
|     { |  | ||||||
|         switch (factor.Type) |  | ||||||
|         { |  | ||||||
|             case AccountAuthFactorType.EmailCode: |  | ||||||
|             case AccountAuthFactorType.InAppCode: |  | ||||||
|                 var correctCode = await _GetFactorCode(factor); |  | ||||||
|                 var isCorrect = correctCode is not null && |  | ||||||
|                                 string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase); |  | ||||||
|                 await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code"); |  | ||||||
|                 return isCorrect; |  | ||||||
|             case AccountAuthFactorType.Password: |  | ||||||
|             case AccountAuthFactorType.TimedCode: |  | ||||||
|             default: |  | ||||||
|                 return factor.VerifyPassword(code); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private const string AuthFactorCachePrefix = "authfactor:"; |  | ||||||
|  |  | ||||||
|     private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires) |  | ||||||
|     { |  | ||||||
|         await cache.SetAsync( |  | ||||||
|             $"{AuthFactorCachePrefix}{factor.Id}:code", |  | ||||||
|             code, |  | ||||||
|             expires |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<string?> _GetFactorCode(AccountAuthFactor factor) |  | ||||||
|     { |  | ||||||
|         return await cache.GetAsync<string?>( |  | ||||||
|             $"{AuthFactorCachePrefix}{factor.Id}:code" |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label) |  | ||||||
|     { |  | ||||||
|         var session = await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.Id == sessionId && s.AccountId == account.Id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (session is null) throw new InvalidOperationException("Session was not found."); |  | ||||||
|  |  | ||||||
|         await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) |  | ||||||
|             .ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label)); |  | ||||||
|  |  | ||||||
|         var sessions = await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) |  | ||||||
|             .ToListAsync(); |  | ||||||
|         foreach (var item in sessions) |  | ||||||
|             await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); |  | ||||||
|  |  | ||||||
|         return session; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task DeleteSession(Account account, Guid sessionId) |  | ||||||
|     { |  | ||||||
|         var session = await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.Id == sessionId && s.AccountId == account.Id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (session is null) throw new InvalidOperationException("Session was not found."); |  | ||||||
|  |  | ||||||
|         var sessions = await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         if (session.Challenge.DeviceId is not null) |  | ||||||
|             await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); |  | ||||||
|  |  | ||||||
|         // The current session should be included in the sessions' list |  | ||||||
|         await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) |  | ||||||
|             .ExecuteDeleteAsync(); |  | ||||||
|  |  | ||||||
|         foreach (var item in sessions) |  | ||||||
|             await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content) |  | ||||||
|     { |  | ||||||
|         var contact = new AccountContact |  | ||||||
|         { |  | ||||||
|             Type = type, |  | ||||||
|             Content = content, |  | ||||||
|             AccountId = account.Id, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.AccountContacts.Add(contact); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return contact; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task VerifyContactMethod(Account account, AccountContact contact) |  | ||||||
|     { |  | ||||||
|         var spell = await spells.CreateMagicSpell( |  | ||||||
|             account, |  | ||||||
|             MagicSpellType.ContactVerification, |  | ||||||
|             new Dictionary<string, object> { { "contact_method", contact.Content } }, |  | ||||||
|             expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), |  | ||||||
|             preventRepeat: true |  | ||||||
|         ); |  | ||||||
|         await spells.NotifyMagicSpell(spell); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<AccountContact> SetContactMethodPrimary(Account account, AccountContact contact) |  | ||||||
|     { |  | ||||||
|         if (contact.AccountId != account.Id) |  | ||||||
|             throw new InvalidOperationException("Contact method does not belong to this account."); |  | ||||||
|         if (contact.VerifiedAt is null) |  | ||||||
|             throw new InvalidOperationException("Cannot set unverified contact method as primary."); |  | ||||||
|  |  | ||||||
|         await using var transaction = await db.Database.BeginTransactionAsync(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await db.AccountContacts |  | ||||||
|                 .Where(c => c.AccountId == account.Id && c.Type == contact.Type) |  | ||||||
|                 .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false)); |  | ||||||
|  |  | ||||||
|             contact.IsPrimary = true; |  | ||||||
|             db.AccountContacts.Update(contact); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|             await transaction.CommitAsync(); |  | ||||||
|             return contact; |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             await transaction.RollbackAsync(); |  | ||||||
|             throw; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task DeleteContactMethod(Account account, AccountContact contact) |  | ||||||
|     { |  | ||||||
|         if (contact.AccountId != account.Id) |  | ||||||
|             throw new InvalidOperationException("Contact method does not belong to this account."); |  | ||||||
|         if (contact.IsPrimary) |  | ||||||
|             throw new InvalidOperationException("Cannot delete primary contact method."); |  | ||||||
|  |  | ||||||
|         db.AccountContacts.Remove(contact); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// This method will grant a badge to the account. |  | ||||||
|     /// Shouldn't be exposed to normal user and the user itself. |  | ||||||
|     /// </summary> |  | ||||||
|     public async Task<Badge> GrantBadge(Account account, Badge badge) |  | ||||||
|     { |  | ||||||
|         badge.AccountId = account.Id; |  | ||||||
|         db.Badges.Add(badge); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|         return badge; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// This method will revoke a badge from the account. |  | ||||||
|     /// Shouldn't be exposed to normal user and the user itself. |  | ||||||
|     /// </summary> |  | ||||||
|     public async Task RevokeBadge(Account account, Guid badgeId) |  | ||||||
|     { |  | ||||||
|         var badge = await db.Badges |  | ||||||
|             .Where(b => b.AccountId == account.Id && b.Id == badgeId) |  | ||||||
|             .OrderByDescending(b => b.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (badge is null) throw new InvalidOperationException("Badge was not found."); |  | ||||||
|  |  | ||||||
|         var profile = await db.AccountProfiles |  | ||||||
|             .Where(p => p.AccountId == account.Id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id) |  | ||||||
|             profile.ActiveBadge = null; |  | ||||||
|  |  | ||||||
|         db.Remove(badge); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task ActiveBadge(Account account, Guid badgeId) |  | ||||||
|     { |  | ||||||
|         await using var transaction = await db.Database.BeginTransactionAsync(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var badge = await db.Badges |  | ||||||
|                 .Where(b => b.AccountId == account.Id && b.Id == badgeId) |  | ||||||
|                 .OrderByDescending(b => b.CreatedAt) |  | ||||||
|                 .FirstOrDefaultAsync(); |  | ||||||
|             if (badge is null) throw new InvalidOperationException("Badge was not found."); |  | ||||||
|  |  | ||||||
|             await db.Badges |  | ||||||
|                 .Where(b => b.AccountId == account.Id && b.Id != badgeId) |  | ||||||
|                 .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null)); |  | ||||||
|  |  | ||||||
|             badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|             db.Update(badge); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|             await db.AccountProfiles |  | ||||||
|                 .Where(p => p.AccountId == account.Id) |  | ||||||
|                 .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference())); |  | ||||||
|             await PurgeAccountCache(account); |  | ||||||
|  |  | ||||||
|             await transaction.CommitAsync(); |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             await transaction.RollbackAsync(); |  | ||||||
|             throw; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The maintenance method for server administrator. |  | ||||||
|     /// To check every user has an account profile and to create them if it isn't having one. |  | ||||||
|     /// </summary> |  | ||||||
|     public async Task EnsureAccountProfileCreated() |  | ||||||
|     { |  | ||||||
|         var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync(); |  | ||||||
|         var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync(); |  | ||||||
|         var missingId = accountsId.Except(existingId).ToList(); |  | ||||||
|  |  | ||||||
|         if (missingId.Count != 0) |  | ||||||
|         { |  | ||||||
|             var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList(); |  | ||||||
|             await db.BulkInsertAsync(newProfiles); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,105 +0,0 @@ | |||||||
| using System.Text.RegularExpressions; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Service for handling username generation and validation |  | ||||||
| /// </summary> |  | ||||||
| public class AccountUsernameService(AppDatabase db) |  | ||||||
| { |  | ||||||
|     private readonly Random _random = new(); |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Generates a unique username based on the provided base name |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="baseName">The preferred username</param> |  | ||||||
|     /// <returns>A unique username</returns> |  | ||||||
|     public async Task<string> GenerateUniqueUsernameAsync(string baseName) |  | ||||||
|     { |  | ||||||
|         // Sanitize the base name |  | ||||||
|         var sanitized = SanitizeUsername(baseName); |  | ||||||
|  |  | ||||||
|         // If the base name is empty after sanitization, use a default |  | ||||||
|         if (string.IsNullOrEmpty(sanitized)) |  | ||||||
|         { |  | ||||||
|             sanitized = "user"; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Check if the sanitized name is available |  | ||||||
|         if (!await IsUsernameExistsAsync(sanitized)) |  | ||||||
|         { |  | ||||||
|             return sanitized; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Try up to 10 times with random numbers |  | ||||||
|         for (int i = 0; i < 10; i++) |  | ||||||
|         { |  | ||||||
|             var suffix = _random.Next(1000, 9999); |  | ||||||
|             var candidate = $"{sanitized}{suffix}"; |  | ||||||
|  |  | ||||||
|             if (!await IsUsernameExistsAsync(candidate)) |  | ||||||
|             { |  | ||||||
|                 return candidate; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // If all attempts fail, use a timestamp |  | ||||||
|         var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); |  | ||||||
|         return $"{sanitized}{timestamp}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Sanitizes a username by removing invalid characters and converting to lowercase |  | ||||||
|     /// </summary> |  | ||||||
|     public string SanitizeUsername(string username) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(username)) |  | ||||||
|             return string.Empty; |  | ||||||
|  |  | ||||||
|         // Replace spaces and special characters with underscores |  | ||||||
|         var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", ""); |  | ||||||
|  |  | ||||||
|         // Convert to lowercase |  | ||||||
|         sanitized = sanitized.ToLowerInvariant(); |  | ||||||
|  |  | ||||||
|         // Ensure it starts with a letter |  | ||||||
|         if (sanitized.Length > 0 && !char.IsLetter(sanitized[0])) |  | ||||||
|         { |  | ||||||
|             sanitized = "u" + sanitized; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Truncate if too long |  | ||||||
|         if (sanitized.Length > 30) |  | ||||||
|         { |  | ||||||
|             sanitized = sanitized[..30]; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return sanitized; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Checks if a username already exists |  | ||||||
|     /// </summary> |  | ||||||
|     public async Task<bool> IsUsernameExistsAsync(string username) |  | ||||||
|     { |  | ||||||
|         return await db.Accounts.AnyAsync(a => a.Name == username); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Generates a username from an email address |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="email">The email address to generate a username from</param> |  | ||||||
|     /// <returns>A unique username derived from the email</returns> |  | ||||||
|     public async Task<string> GenerateUsernameFromEmailAsync(string email) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(email)) |  | ||||||
|             return await GenerateUniqueUsernameAsync("user"); |  | ||||||
|  |  | ||||||
|         // Extract the local part of the email (before the @) |  | ||||||
|         var localPart = email.Split('@')[0]; |  | ||||||
|  |  | ||||||
|         // Use the local part as the base for username generation |  | ||||||
|         return await GenerateUniqueUsernameAsync(localPart); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,46 +0,0 @@ | |||||||
| using Quartz; |  | ||||||
| using DysonNetwork.Sphere.Connection; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using DysonNetwork.Sphere.Storage.Handlers; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class ActionLogService(GeoIpService geo, FlushBufferService fbs) |  | ||||||
| { |  | ||||||
|     public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta) |  | ||||||
|     { |  | ||||||
|         var log = new ActionLog |  | ||||||
|         { |  | ||||||
|             Action = action, |  | ||||||
|             AccountId = accountId, |  | ||||||
|             Meta = meta, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         fbs.Enqueue(log); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request, |  | ||||||
|         Account? account = null) |  | ||||||
|     { |  | ||||||
|         var log = new ActionLog |  | ||||||
|         { |  | ||||||
|             Action = action, |  | ||||||
|             Meta = meta, |  | ||||||
|             UserAgent = request.Headers.UserAgent, |  | ||||||
|             IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(), |  | ||||||
|             Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) |  | ||||||
|         }; |  | ||||||
|          |  | ||||||
|         if (request.HttpContext.Items["CurrentUser"] is Account currentUser) |  | ||||||
|             log.AccountId = currentUser.Id; |  | ||||||
|         else if (account != null) |  | ||||||
|             log.AccountId = account.Id; |  | ||||||
|         else |  | ||||||
|             throw new ArgumentException("No user context was found"); |  | ||||||
|          |  | ||||||
|         if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession) |  | ||||||
|             log.SessionId = currentSession.Id; |  | ||||||
|  |  | ||||||
|         fbs.Enqueue(log); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,47 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class Badge : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(1024)] public string Type { get; set; } = null!; |  | ||||||
|     [MaxLength(1024)] public string? Label { get; set; } |  | ||||||
|     [MaxLength(4096)] public string? Caption { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); |  | ||||||
|     public Instant? ActivatedAt { get; set; } |  | ||||||
|     public Instant? ExpiredAt { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public BadgeReferenceObject ToReference() |  | ||||||
|     { |  | ||||||
|         return new BadgeReferenceObject |  | ||||||
|         { |  | ||||||
|             Id = Id, |  | ||||||
|             Type = Type, |  | ||||||
|             Label = Label, |  | ||||||
|             Caption = Caption, |  | ||||||
|             Meta = Meta, |  | ||||||
|             ActivatedAt = ActivatedAt, |  | ||||||
|             ExpiredAt = ExpiredAt, |  | ||||||
|             AccountId = AccountId |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class BadgeReferenceObject : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } |  | ||||||
|     public string Type { get; set; } = null!; |  | ||||||
|     public string? Label { get; set; } |  | ||||||
|     public string? Caption { get; set; } |  | ||||||
|     public Dictionary<string, object>? Meta { get; set; } |  | ||||||
|     public Instant? ActivatedAt { get; set; } |  | ||||||
|     public Instant? ExpiredAt { get; set; } |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,65 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public enum StatusAttitude |  | ||||||
| { |  | ||||||
|     Positive, |  | ||||||
|     Negative, |  | ||||||
|     Neutral |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class Status : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     public StatusAttitude Attitude { get; set; } |  | ||||||
|     [NotMapped] public bool IsOnline { get; set; } |  | ||||||
|     [NotMapped] public bool IsCustomized { get; set; } = true; |  | ||||||
|     public bool IsInvisible { get; set; } |  | ||||||
|     public bool IsNotDisturb { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Label { get; set; } |  | ||||||
|     public Instant? ClearedAt { get; set; } |  | ||||||
|      |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum CheckInResultLevel |  | ||||||
| { |  | ||||||
|     Worst, |  | ||||||
|     Worse, |  | ||||||
|     Normal, |  | ||||||
|     Better, |  | ||||||
|     Best |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class CheckInResult : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     public CheckInResultLevel Level { get; set; } |  | ||||||
|     public decimal? RewardPoints { get; set; } |  | ||||||
|     public int? RewardExperience { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>(); |  | ||||||
|      |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class FortuneTip |  | ||||||
| { |  | ||||||
|     public bool IsPositive { get; set; } |  | ||||||
|     public string Title { get; set; } = null!; |  | ||||||
|     public string Content { get; set; } = null!; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// This method should not be mapped. Used to generate the daily event calendar. |  | ||||||
| /// </summary> |  | ||||||
| public class DailyEventResponse |  | ||||||
| { |  | ||||||
|     public Instant Date { get; set; } |  | ||||||
|     public CheckInResult? CheckInResult { get; set; } |  | ||||||
|     public ICollection<Status> Statuses { get; set; } = new List<Status>(); |  | ||||||
| } |  | ||||||
| @@ -1,30 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public enum MagicSpellType |  | ||||||
| { |  | ||||||
|     AccountActivation, |  | ||||||
|     AccountDeactivation, |  | ||||||
|     AccountRemoval, |  | ||||||
|     AuthPasswordReset, |  | ||||||
|     ContactVerification, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| [Index(nameof(Spell), IsUnique = true)] |  | ||||||
| public class MagicSpell : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [JsonIgnore] [MaxLength(1024)] public string Spell { get; set; } = null!; |  | ||||||
|     public MagicSpellType Type { get; set; } |  | ||||||
|     public Instant? ExpiresAt { get; set; } |  | ||||||
|     public Instant? AffectedAt { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new(); |  | ||||||
|  |  | ||||||
|     public Guid? AccountId { get; set; } |  | ||||||
|     public Account? Account { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/spells")] |  | ||||||
| public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase |  | ||||||
| { |  | ||||||
|     [HttpPost("{spellId:guid}/resend")] |  | ||||||
|     public async Task<ActionResult> ResendMagicSpell(Guid spellId) |  | ||||||
|     { |  | ||||||
|         var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId); |  | ||||||
|         if (spell == null) |  | ||||||
|             return NotFound(); |  | ||||||
|      |  | ||||||
|         await sp.NotifyMagicSpell(spell, true); |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,252 +0,0 @@ | |||||||
| using System.Globalization; |  | ||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Email; |  | ||||||
| using DysonNetwork.Sphere.Pages.Emails; |  | ||||||
| using DysonNetwork.Sphere.Permission; |  | ||||||
| using DysonNetwork.Sphere.Resources.Localization; |  | ||||||
| using DysonNetwork.Sphere.Resources.Pages.Emails; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.Extensions.Localization; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class MagicSpellService( |  | ||||||
|     AppDatabase db, |  | ||||||
|     EmailService email, |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     ILogger<MagicSpellService> logger, |  | ||||||
|     IStringLocalizer<Localization.EmailResource> localizer |  | ||||||
| ) |  | ||||||
| { |  | ||||||
|     public async Task<MagicSpell> CreateMagicSpell( |  | ||||||
|         Account account, |  | ||||||
|         MagicSpellType type, |  | ||||||
|         Dictionary<string, object> meta, |  | ||||||
|         Instant? expiredAt = null, |  | ||||||
|         Instant? affectedAt = null, |  | ||||||
|         bool preventRepeat = false |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         if (preventRepeat) |  | ||||||
|         { |  | ||||||
|             var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|             var existingSpell = await db.MagicSpells |  | ||||||
|                 .Where(s => s.AccountId == account.Id) |  | ||||||
|                 .Where(s => s.Type == type) |  | ||||||
|                 .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) |  | ||||||
|                 .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|             if (existingSpell != null) |  | ||||||
|             { |  | ||||||
|                 throw new InvalidOperationException($"Account already has an active magic spell of type {type}"); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var spellWord = _GenerateRandomString(128); |  | ||||||
|         var spell = new MagicSpell |  | ||||||
|         { |  | ||||||
|             Spell = spellWord, |  | ||||||
|             Type = type, |  | ||||||
|             ExpiresAt = expiredAt, |  | ||||||
|             AffectedAt = affectedAt, |  | ||||||
|             AccountId = account.Id, |  | ||||||
|             Meta = meta |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.MagicSpells.Add(spell); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return spell; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) |  | ||||||
|     { |  | ||||||
|         var contact = await db.AccountContacts |  | ||||||
|             .Where(c => c.Account.Id == spell.AccountId) |  | ||||||
|             .Where(c => c.Type == AccountContactType.Email) |  | ||||||
|             .Where(c => c.VerifiedAt != null || bypassVerify) |  | ||||||
|             .OrderByDescending(c => c.IsPrimary) |  | ||||||
|             .Include(c => c.Account) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (contact is null) throw new ArgumentException("Account has no contact method that can use"); |  | ||||||
|  |  | ||||||
|         var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}"; |  | ||||||
|  |  | ||||||
|         logger.LogInformation("Sending magic spell... {Link}", link); |  | ||||||
|  |  | ||||||
|         var accountLanguage = await db.Accounts |  | ||||||
|             .Where(a => a.Id == spell.AccountId) |  | ||||||
|             .Select(a => a.Language) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         AccountService.SetCultureInfo(accountLanguage); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             switch (spell.Type) |  | ||||||
|             { |  | ||||||
|                 case MagicSpellType.AccountActivation: |  | ||||||
|                     await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>( |  | ||||||
|                         contact.Account.Nick, |  | ||||||
|                         contact.Content, |  | ||||||
|                         localizer["EmailLandingTitle"], |  | ||||||
|                         new LandingEmailModel |  | ||||||
|                         { |  | ||||||
|                             Name = contact.Account.Name, |  | ||||||
|                             Link = link |  | ||||||
|                         } |  | ||||||
|                     ); |  | ||||||
|                     break; |  | ||||||
|                 case MagicSpellType.AccountRemoval: |  | ||||||
|                     await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>( |  | ||||||
|                         contact.Account.Nick, |  | ||||||
|                         contact.Content, |  | ||||||
|                         localizer["EmailAccountDeletionTitle"], |  | ||||||
|                         new AccountDeletionEmailModel |  | ||||||
|                         { |  | ||||||
|                             Name = contact.Account.Name, |  | ||||||
|                             Link = link |  | ||||||
|                         } |  | ||||||
|                     ); |  | ||||||
|                     break; |  | ||||||
|                 case MagicSpellType.AuthPasswordReset: |  | ||||||
|                     await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>( |  | ||||||
|                         contact.Account.Nick, |  | ||||||
|                         contact.Content, |  | ||||||
|                         localizer["EmailAccountDeletionTitle"], |  | ||||||
|                         new PasswordResetEmailModel |  | ||||||
|                         { |  | ||||||
|                             Name = contact.Account.Name, |  | ||||||
|                             Link = link |  | ||||||
|                         } |  | ||||||
|                     ); |  | ||||||
|                     break; |  | ||||||
|                 case MagicSpellType.ContactVerification: |  | ||||||
|                     if (spell.Meta["contact_method"] is not string contactMethod) |  | ||||||
|                         throw new InvalidOperationException("Contact method is not found."); |  | ||||||
|                     await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>( |  | ||||||
|                         contact.Account.Nick, |  | ||||||
|                         contactMethod!, |  | ||||||
|                         localizer["EmailContactVerificationTitle"], |  | ||||||
|                         new ContactVerificationEmailModel |  | ||||||
|                         { |  | ||||||
|                             Name = contact.Account.Name, |  | ||||||
|                             Link = link |  | ||||||
|                         } |  | ||||||
|                     ); |  | ||||||
|                     break; |  | ||||||
|                 default: |  | ||||||
|                     throw new ArgumentOutOfRangeException(); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch (Exception err) |  | ||||||
|         { |  | ||||||
|             logger.LogError($"Error sending magic spell (${spell.Spell})... {err}"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task ApplyMagicSpell(MagicSpell spell) |  | ||||||
|     { |  | ||||||
|         switch (spell.Type) |  | ||||||
|         { |  | ||||||
|             case MagicSpellType.AuthPasswordReset: |  | ||||||
|                 throw new ArgumentException( |  | ||||||
|                     "For password reset spell, please use the ApplyPasswordReset method instead." |  | ||||||
|                 ); |  | ||||||
|             case MagicSpellType.AccountRemoval: |  | ||||||
|                 var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); |  | ||||||
|                 if (account is null) break; |  | ||||||
|                 db.Accounts.Remove(account); |  | ||||||
|                 break; |  | ||||||
|             case MagicSpellType.AccountActivation: |  | ||||||
|                 var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString(); |  | ||||||
|                 var contact = await |  | ||||||
|                     db.AccountContacts.FirstOrDefaultAsync(c => |  | ||||||
|                         c.Content == contactMethod |  | ||||||
|                     ); |  | ||||||
|                 if (contact is not null) |  | ||||||
|                 { |  | ||||||
|                     contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                     db.Update(contact); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); |  | ||||||
|                 if (account is not null) |  | ||||||
|                 { |  | ||||||
|                     account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                     db.Update(account); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); |  | ||||||
|                 if (defaultGroup is not null && account is not null) |  | ||||||
|                 { |  | ||||||
|                     db.PermissionGroupMembers.Add(new PermissionGroupMember |  | ||||||
|                     { |  | ||||||
|                         Actor = $"user:{account.Id}", |  | ||||||
|                         Group = defaultGroup |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 break; |  | ||||||
|             case MagicSpellType.ContactVerification: |  | ||||||
|                 var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString(); |  | ||||||
|                 var verifyContact = await db.AccountContacts |  | ||||||
|                     .FirstOrDefaultAsync(c => c.Content == verifyContactMethod); |  | ||||||
|                 if (verifyContact is not null) |  | ||||||
|                 { |  | ||||||
|                     verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                     db.Update(verifyContact); |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 break; |  | ||||||
|             default: |  | ||||||
|                 throw new ArgumentOutOfRangeException(); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         db.Remove(spell); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task ApplyPasswordReset(MagicSpell spell, string newPassword) |  | ||||||
|     { |  | ||||||
|         if (spell.Type != MagicSpellType.AuthPasswordReset) |  | ||||||
|             throw new ArgumentException("This spell is not a password reset spell."); |  | ||||||
|  |  | ||||||
|         var passwordFactor = await db.AccountAuthFactors |  | ||||||
|             .Include(f => f.Account) |  | ||||||
|             .Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (passwordFactor is null) |  | ||||||
|         { |  | ||||||
|             var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); |  | ||||||
|             if (account is null) throw new InvalidOperationException("Both account and auth factor was not found."); |  | ||||||
|             passwordFactor = new AccountAuthFactor |  | ||||||
|             { |  | ||||||
|                 Type = AccountAuthFactorType.Password, |  | ||||||
|                 Account = account, |  | ||||||
|                 Secret = newPassword |  | ||||||
|             }.HashSecret(); |  | ||||||
|             db.AccountAuthFactors.Add(passwordFactor); |  | ||||||
|         } |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             passwordFactor.Secret = newPassword; |  | ||||||
|             passwordFactor.HashSecret(); |  | ||||||
|             db.Update(passwordFactor); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static string _GenerateRandomString(int length) |  | ||||||
|     { |  | ||||||
|         using var rng = RandomNumberGenerator.Create(); |  | ||||||
|         var randomBytes = new byte[length]; |  | ||||||
|         rng.GetBytes(randomBytes); |  | ||||||
|  |  | ||||||
|         var base64String = Convert.ToBase64String(randomBytes); |  | ||||||
|  |  | ||||||
|         return base64String.Substring(0, length); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,41 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.ComponentModel.DataAnnotations.Schema; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class Notification : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(1024)] public string Topic { get; set; } = null!; |  | ||||||
|     [MaxLength(1024)] public string? Title { get; set; } |  | ||||||
|     [MaxLength(2048)] public string? Subtitle { get; set; } |  | ||||||
|     [MaxLength(4096)] public string? Content { get; set; } |  | ||||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } |  | ||||||
|     public int Priority { get; set; } = 10; |  | ||||||
|     public Instant? ViewedAt { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum NotificationPushProvider |  | ||||||
| { |  | ||||||
|     Apple, |  | ||||||
|     Google |  | ||||||
| } |  | ||||||
|  |  | ||||||
| [Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)] |  | ||||||
| public class NotificationPushSubscription : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid Id { get; set; } = Guid.NewGuid(); |  | ||||||
|     [MaxLength(4096)] public string DeviceId { get; set; } = null!; |  | ||||||
|     [MaxLength(4096)] public string DeviceToken { get; set; } = null!; |  | ||||||
|     public NotificationPushProvider Provider { get; set; } |  | ||||||
|     public Instant? LastUsedAt { get; set; } |  | ||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |  | ||||||
| } |  | ||||||
| @@ -1,166 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using DysonNetwork.Sphere.Auth; |  | ||||||
| using DysonNetwork.Sphere.Permission; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/notifications")] |  | ||||||
| public class NotificationController(AppDatabase 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(); |  | ||||||
|  |  | ||||||
|         var count = await db.Notifications |  | ||||||
|             .Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null) |  | ||||||
|             .CountAsync(); |  | ||||||
|         return Ok(count); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<List<Notification>>> ListNotifications( |  | ||||||
|         [FromQuery] int offset = 0, |  | ||||||
|         // The page size set to 5 is to avoid the client pulled the notification |  | ||||||
|         // but didn't render it in the screen-viewable region. |  | ||||||
|         [FromQuery] int take = 5 |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); |  | ||||||
|         if (currentUserValue is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var totalCount = await db.Notifications |  | ||||||
|             .Where(s => s.AccountId == currentUser.Id) |  | ||||||
|             .CountAsync(); |  | ||||||
|         var notifications = await db.Notifications |  | ||||||
|             .Where(s => s.AccountId == currentUser.Id) |  | ||||||
|             .OrderByDescending(e => e.CreatedAt) |  | ||||||
|             .Skip(offset) |  | ||||||
|             .Take(take) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         Response.Headers["X-Total"] = totalCount.ToString(); |  | ||||||
|         await nty.MarkNotificationsViewed(notifications); |  | ||||||
|  |  | ||||||
|         return Ok(notifications); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class PushNotificationSubscribeRequest |  | ||||||
|     { |  | ||||||
|         [MaxLength(4096)] public string DeviceToken { get; set; } = null!; |  | ||||||
|         public NotificationPushProvider Provider { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPut("subscription")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification( |  | ||||||
|         [FromBody] PushNotificationSubscribeRequest request |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); |  | ||||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); |  | ||||||
|         var currentUser = currentUserValue as Account; |  | ||||||
|         if (currentUser == null) return Unauthorized(); |  | ||||||
|         var currentSession = currentSessionValue as Session; |  | ||||||
|         if (currentSession == null) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var result = |  | ||||||
|             await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!, |  | ||||||
|                 request.DeviceToken); |  | ||||||
|  |  | ||||||
|         return Ok(result); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("subscription")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<int>> UnsubscribeFromPushNotification() |  | ||||||
|     { |  | ||||||
|         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); |  | ||||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); |  | ||||||
|         var currentUser = currentUserValue as Account; |  | ||||||
|         if (currentUser == null) return Unauthorized(); |  | ||||||
|         var currentSession = currentSessionValue as Session; |  | ||||||
|         if (currentSession == null) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var affectedRows = await db.NotificationPushSubscriptions |  | ||||||
|             .Where(s => |  | ||||||
|                 s.AccountId == currentUser.Id && |  | ||||||
|                 s.DeviceId == currentSession.Challenge.DeviceId |  | ||||||
|             ).ExecuteDeleteAsync(); |  | ||||||
|         return Ok(affectedRows); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class NotificationRequest |  | ||||||
|     { |  | ||||||
|         [Required] [MaxLength(1024)] public string Topic { get; set; } = null!; |  | ||||||
|         [Required] [MaxLength(1024)] public string Title { get; set; } = null!; |  | ||||||
|         [MaxLength(2048)] public string? Subtitle { get; set; } |  | ||||||
|         [Required] [MaxLength(4096)] public string Content { get; set; } = null!; |  | ||||||
|         public Dictionary<string, object>? Meta { get; set; } |  | ||||||
|         public int Priority { get; set; } = 10; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("broadcast")] |  | ||||||
|     [Authorize] |  | ||||||
|     [RequiredPermission("global", "notifications.broadcast")] |  | ||||||
|     public async Task<ActionResult> BroadcastNotification( |  | ||||||
|         [FromBody] NotificationRequest request, |  | ||||||
|         [FromQuery] bool save = false |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         await nty.BroadcastNotification( |  | ||||||
|             new Notification |  | ||||||
|             { |  | ||||||
|                 CreatedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 UpdatedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 Topic = request.Topic, |  | ||||||
|                 Title = request.Title, |  | ||||||
|                 Subtitle = request.Subtitle, |  | ||||||
|                 Content = request.Content, |  | ||||||
|                 Meta = request.Meta, |  | ||||||
|                 Priority = request.Priority, |  | ||||||
|             }, |  | ||||||
|             save |  | ||||||
|         ); |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class NotificationWithAimRequest : NotificationRequest |  | ||||||
|     { |  | ||||||
|         [Required] public List<Guid> AccountId { get; set; } = null!; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     [HttpPost("send")] |  | ||||||
|     [Authorize] |  | ||||||
|     [RequiredPermission("global", "notifications.send")] |  | ||||||
|     public async Task<ActionResult> SendNotification( |  | ||||||
|         [FromBody] NotificationWithAimRequest request, |  | ||||||
|         [FromQuery] bool save = false |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync(); |  | ||||||
|         await nty.SendNotificationBatch( |  | ||||||
|             new Notification |  | ||||||
|             { |  | ||||||
|                 CreatedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 UpdatedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 Topic = request.Topic, |  | ||||||
|                 Title = request.Title, |  | ||||||
|                 Subtitle = request.Subtitle, |  | ||||||
|                 Content = request.Content, |  | ||||||
|                 Meta = request.Meta, |  | ||||||
|             }, |  | ||||||
|             accounts, |  | ||||||
|             save |  | ||||||
|         ); |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,307 +0,0 @@ | |||||||
| using System.Text; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Connection; |  | ||||||
| using EFCore.BulkExtensions; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class NotificationService( |  | ||||||
|     AppDatabase db, |  | ||||||
|     WebSocketService ws, |  | ||||||
|     IHttpClientFactory httpFactory, |  | ||||||
|     IConfiguration config) |  | ||||||
| { |  | ||||||
|     private readonly string _notifyTopic = config["Notifications:Topic"]!; |  | ||||||
|     private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); |  | ||||||
|  |  | ||||||
|     public async Task UnsubscribePushNotifications(string deviceId) |  | ||||||
|     { |  | ||||||
|         await db.NotificationPushSubscriptions |  | ||||||
|             .Where(s => s.DeviceId == deviceId) |  | ||||||
|             .ExecuteDeleteAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<NotificationPushSubscription> SubscribePushNotification( |  | ||||||
|         Account account, |  | ||||||
|         NotificationPushProvider provider, |  | ||||||
|         string deviceId, |  | ||||||
|         string deviceToken |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|          |  | ||||||
|         // First check if a matching subscription exists |  | ||||||
|         var existingSubscription = await db.NotificationPushSubscriptions |  | ||||||
|             .Where(s => s.AccountId == account.Id) |  | ||||||
|             .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|         if (existingSubscription is not null) |  | ||||||
|         { |  | ||||||
|             // Update the existing subscription directly in the database |  | ||||||
|             await db.NotificationPushSubscriptions |  | ||||||
|                 .Where(s => s.Id == existingSubscription.Id) |  | ||||||
|                 .ExecuteUpdateAsync(setters => setters |  | ||||||
|                     .SetProperty(s => s.DeviceId, deviceId) |  | ||||||
|                     .SetProperty(s => s.DeviceToken, deviceToken) |  | ||||||
|                     .SetProperty(s => s.UpdatedAt, now)); |  | ||||||
|  |  | ||||||
|             // Return the updated subscription |  | ||||||
|             existingSubscription.DeviceId = deviceId; |  | ||||||
|             existingSubscription.DeviceToken = deviceToken; |  | ||||||
|             existingSubscription.UpdatedAt = now; |  | ||||||
|             return existingSubscription; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var subscription = new NotificationPushSubscription |  | ||||||
|         { |  | ||||||
|             DeviceId = deviceId, |  | ||||||
|             DeviceToken = deviceToken, |  | ||||||
|             Provider = provider, |  | ||||||
|             AccountId = account.Id, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.NotificationPushSubscriptions.Add(subscription); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return subscription; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Notification> SendNotification( |  | ||||||
|         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 |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         if (title is null && subtitle is null && content is null) |  | ||||||
|             throw new ArgumentException("Unable to send notification that completely empty."); |  | ||||||
|  |  | ||||||
|         meta ??= new Dictionary<string, object>(); |  | ||||||
|         if (actionUri is not null) meta["action_uri"] = actionUri; |  | ||||||
|  |  | ||||||
|         var notification = new Notification |  | ||||||
|         { |  | ||||||
|             Topic = topic, |  | ||||||
|             Title = title, |  | ||||||
|             Subtitle = subtitle, |  | ||||||
|             Content = content, |  | ||||||
|             Meta = meta, |  | ||||||
|             AccountId = account.Id, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         if (save) |  | ||||||
|         { |  | ||||||
|             db.Add(notification); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!isSilent) _ = DeliveryNotification(notification); |  | ||||||
|  |  | ||||||
|         return notification; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task DeliveryNotification(Notification notification) |  | ||||||
|     { |  | ||||||
|         ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket |  | ||||||
|         { |  | ||||||
|             Type = "notifications.new", |  | ||||||
|             Data = notification |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         // Pushing the notification |  | ||||||
|         var subscribers = await db.NotificationPushSubscriptions |  | ||||||
|             .Where(s => s.AccountId == notification.AccountId) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         await _PushNotification(notification, subscribers); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task MarkNotificationsViewed(ICollection<Notification> notifications) |  | ||||||
|     { |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList(); |  | ||||||
|         if (id.Count == 0) return; |  | ||||||
|  |  | ||||||
|         await db.Notifications |  | ||||||
|             .Where(n => id.Contains(n.Id)) |  | ||||||
|             .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now) |  | ||||||
|             ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task BroadcastNotification(Notification notification, bool save = false) |  | ||||||
|     { |  | ||||||
|         var accounts = await db.Accounts.ToListAsync(); |  | ||||||
|  |  | ||||||
|         if (save) |  | ||||||
|         { |  | ||||||
|             var notifications = accounts.Select(x => |  | ||||||
|             { |  | ||||||
|                 var newNotification = new Notification |  | ||||||
|                 { |  | ||||||
|                     Topic = notification.Topic, |  | ||||||
|                     Title = notification.Title, |  | ||||||
|                     Subtitle = notification.Subtitle, |  | ||||||
|                     Content = notification.Content, |  | ||||||
|                     Meta = notification.Meta, |  | ||||||
|                     Priority = notification.Priority, |  | ||||||
|                     Account = x, |  | ||||||
|                     AccountId = x.Id |  | ||||||
|                 }; |  | ||||||
|                 return newNotification; |  | ||||||
|             }).ToList(); |  | ||||||
|             await db.BulkInsertAsync(notifications); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         foreach (var account in accounts) |  | ||||||
|         { |  | ||||||
|             notification.Account = account; |  | ||||||
|             notification.AccountId = account.Id; |  | ||||||
|             ws.SendPacketToAccount(account.Id, new WebSocketPacket |  | ||||||
|             { |  | ||||||
|                 Type = "notifications.new", |  | ||||||
|                 Data = notification |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var subscribers = await db.NotificationPushSubscriptions |  | ||||||
|             .ToListAsync(); |  | ||||||
|         await _PushNotification(notification, subscribers); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task SendNotificationBatch(Notification notification, List<Account> accounts, bool save = false) |  | ||||||
|     { |  | ||||||
|         if (save) |  | ||||||
|         { |  | ||||||
|             var notifications = accounts.Select(x => |  | ||||||
|             { |  | ||||||
|                 var newNotification = new Notification |  | ||||||
|                 { |  | ||||||
|                     Topic = notification.Topic, |  | ||||||
|                     Title = notification.Title, |  | ||||||
|                     Subtitle = notification.Subtitle, |  | ||||||
|                     Content = notification.Content, |  | ||||||
|                     Meta = notification.Meta, |  | ||||||
|                     Priority = notification.Priority, |  | ||||||
|                     AccountId = x.Id |  | ||||||
|                 }; |  | ||||||
|                 return newNotification; |  | ||||||
|             }).ToList(); |  | ||||||
|             await db.BulkInsertAsync(notifications); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         foreach (var account in accounts) |  | ||||||
|         { |  | ||||||
|             notification.Account = account; |  | ||||||
|             notification.AccountId = account.Id; |  | ||||||
|             ws.SendPacketToAccount(account.Id, new WebSocketPacket |  | ||||||
|             { |  | ||||||
|                 Type = "notifications.new", |  | ||||||
|                 Data = notification |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var accountsId = accounts.Select(x => x.Id).ToList(); |  | ||||||
|         var subscribers = await db.NotificationPushSubscriptions |  | ||||||
|             .Where(s => accountsId.Contains(s.AccountId)) |  | ||||||
|             .ToListAsync(); |  | ||||||
|         await _PushNotification(notification, subscribers); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification, |  | ||||||
|         IEnumerable<NotificationPushSubscription> subscriptions) |  | ||||||
|     { |  | ||||||
|         var subDict = subscriptions |  | ||||||
|             .GroupBy(x => x.Provider) |  | ||||||
|             .ToDictionary(x => x.Key, x => x.ToList()); |  | ||||||
|  |  | ||||||
|         var notifications = subDict.Select(value => |  | ||||||
|         { |  | ||||||
|             var platformCode = value.Key switch |  | ||||||
|             { |  | ||||||
|                 NotificationPushProvider.Apple => 1, |  | ||||||
|                 NotificationPushProvider.Google => 2, |  | ||||||
|                 _ => throw new InvalidOperationException($"Unknown push provider: {value.Key}") |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             var tokens = value.Value.Select(x => x.DeviceToken).ToList(); |  | ||||||
|             return _BuildNotificationPayload(notification, platformCode, tokens); |  | ||||||
|         }).ToList(); |  | ||||||
|  |  | ||||||
|         return notifications.ToList(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private Dictionary<string, object> _BuildNotificationPayload(Notification notification, int platformCode, |  | ||||||
|         IEnumerable<string> deviceTokens) |  | ||||||
|     { |  | ||||||
|         var alertDict = new Dictionary<string, object>(); |  | ||||||
|         var dict = new Dictionary<string, object> |  | ||||||
|         { |  | ||||||
|             ["notif_id"] = notification.Id.ToString(), |  | ||||||
|             ["apns_id"] = notification.Id.ToString(), |  | ||||||
|             ["topic"] = _notifyTopic, |  | ||||||
|             ["tokens"] = deviceTokens, |  | ||||||
|             ["data"] = new Dictionary<string, object> |  | ||||||
|             { |  | ||||||
|                 ["type"] = notification.Topic, |  | ||||||
|                 ["meta"] = notification.Meta ?? new Dictionary<string, object>(), |  | ||||||
|             }, |  | ||||||
|             ["mutable_content"] = true, |  | ||||||
|             ["priority"] = notification.Priority >= 5 ? "high" : "normal", |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(notification.Title)) |  | ||||||
|         { |  | ||||||
|             dict["title"] = notification.Title; |  | ||||||
|             alertDict["title"] = notification.Title; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(notification.Content)) |  | ||||||
|         { |  | ||||||
|             dict["message"] = notification.Content; |  | ||||||
|             alertDict["body"] = notification.Content; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(notification.Subtitle)) |  | ||||||
|         { |  | ||||||
|             dict["message"] = $"{notification.Subtitle}\n{dict["message"]}"; |  | ||||||
|             alertDict["subtitle"] = notification.Subtitle; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (notification.Priority >= 5) |  | ||||||
|             dict["name"] = "default"; |  | ||||||
|  |  | ||||||
|         dict["platform"] = platformCode; |  | ||||||
|         dict["alert"] = alertDict; |  | ||||||
|  |  | ||||||
|         return dict; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task _PushNotification(Notification notification, |  | ||||||
|         IEnumerable<NotificationPushSubscription> subscriptions) |  | ||||||
|     { |  | ||||||
|         var subList = subscriptions.ToList(); |  | ||||||
|         if (subList.Count == 0) return; |  | ||||||
|  |  | ||||||
|         var requestDict = new Dictionary<string, object> |  | ||||||
|         { |  | ||||||
|             ["notifications"] = _BuildNotificationPayload(notification, subList) |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var client = httpFactory.CreateClient(); |  | ||||||
|         client.BaseAddress = _notifyEndpoint; |  | ||||||
|         var request = await client.PostAsync("/push", new StringContent( |  | ||||||
|             JsonSerializer.Serialize(requestDict), |  | ||||||
|             Encoding.UTF8, |  | ||||||
|             "application/json" |  | ||||||
|         )); |  | ||||||
|         request.EnsureSuccessStatusCode(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,22 +0,0 @@ | |||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public enum RelationshipStatus : short |  | ||||||
| { |  | ||||||
|     Friends = 100, |  | ||||||
|     Pending = 0, |  | ||||||
|     Blocked = -100 |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class Relationship : ModelBase |  | ||||||
| { |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public Account Account { get; set; } = null!; |  | ||||||
|     public Guid RelatedId { get; set; } |  | ||||||
|     public Account Related { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     public Instant? ExpiredAt { get; set; } |  | ||||||
|  |  | ||||||
|     public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending; |  | ||||||
| } |  | ||||||
| @@ -1,253 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/relationships")] |  | ||||||
| public class RelationshipController(AppDatabase 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(); |  | ||||||
|         var userId = currentUser.Id; |  | ||||||
|  |  | ||||||
|         var query = db.AccountRelationships.AsQueryable() |  | ||||||
|             .Where(r => r.RelatedId == userId); |  | ||||||
|         var totalCount = await query.CountAsync(); |  | ||||||
|         var relationships = await query |  | ||||||
|             .Include(r => r.Related) |  | ||||||
|             .Include(r => r.Related.Profile) |  | ||||||
|             .Include(r => r.Account) |  | ||||||
|             .Include(r => r.Account.Profile) |  | ||||||
|             .Skip(offset) |  | ||||||
|             .Take(take) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         var statuses = await db.AccountRelationships |  | ||||||
|             .Where(r => r.AccountId == userId) |  | ||||||
|             .ToDictionaryAsync(r => r.RelatedId); |  | ||||||
|         foreach (var relationship in relationships) |  | ||||||
|             if (statuses.TryGetValue(relationship.RelatedId, out var status)) |  | ||||||
|                 relationship.Status = status.Status; |  | ||||||
|  |  | ||||||
|         Response.Headers["X-Total"] = totalCount.ToString(); |  | ||||||
|  |  | ||||||
|         return relationships; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("requests")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<List<Relationship>>> ListSentRequests() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var relationships = await db.AccountRelationships |  | ||||||
|             .Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending) |  | ||||||
|             .Include(r => r.Related) |  | ||||||
|             .Include(r => r.Related.Profile) |  | ||||||
|             .Include(r => r.Account) |  | ||||||
|             .Include(r => r.Account.Profile) |  | ||||||
|             .ToListAsync(); |  | ||||||
|  |  | ||||||
|         return relationships; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class RelationshipRequest |  | ||||||
|     { |  | ||||||
|         [Required] public RelationshipStatus Status { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("{userId:guid}")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId, |  | ||||||
|         [FromBody] RelationshipRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var relatedUser = await db.Accounts.FindAsync(userId); |  | ||||||
|         if (relatedUser is null) return NotFound("Account was not found."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var relationship = await rels.CreateRelationship( |  | ||||||
|                 currentUser, relatedUser, request.Status |  | ||||||
|             ); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch("{userId:guid}")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId, |  | ||||||
|         [FromBody] RelationshipRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var relationship = await rels.UpdateRelationship(currentUser.Id, userId, request.Status); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (ArgumentException err) |  | ||||||
|         { |  | ||||||
|             return NotFound(err.Message); |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("{userId:guid}")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> GetRelationship(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var now = Instant.FromDateTimeUtc(DateTime.UtcNow); |  | ||||||
|         var queries = db.AccountRelationships.AsQueryable() |  | ||||||
|             .Where(r => r.AccountId == currentUser.Id && r.RelatedId == userId) |  | ||||||
|             .Where(r => r.ExpiredAt == null || r.ExpiredAt > now); |  | ||||||
|         var relationship = await queries |  | ||||||
|             .Include(r => r.Related) |  | ||||||
|             .Include(r => r.Related.Profile) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (relationship is null) return NotFound(); |  | ||||||
|  |  | ||||||
|         relationship.Account = currentUser; |  | ||||||
|         return Ok(relationship); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("{userId:guid}/friends")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var relatedUser = await db.Accounts.FindAsync(userId); |  | ||||||
|         if (relatedUser is null) return NotFound("Account was not found."); |  | ||||||
|  |  | ||||||
|         var existing = await db.AccountRelationships.FirstOrDefaultAsync(r => |  | ||||||
|             (r.AccountId == currentUser.Id && r.RelatedId == userId) || |  | ||||||
|             (r.AccountId == userId && r.RelatedId == currentUser.Id)); |  | ||||||
|         if (existing != null) return BadRequest("Relationship already exists."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var relationship = await rels.SendFriendRequest(currentUser, relatedUser); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("{userId:guid}/friends")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult> DeleteFriendRequest(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await rels.DeleteFriendRequest(currentUser.Id, userId); |  | ||||||
|             return NoContent(); |  | ||||||
|         } |  | ||||||
|         catch (ArgumentException err) |  | ||||||
|         { |  | ||||||
|             return NotFound(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("{userId:guid}/friends/accept")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not 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."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             relationship = await rels.AcceptFriendRelationship(relationship); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("{userId:guid}/friends/decline")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not 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."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             relationship = await rels.AcceptFriendRelationship(relationship, status: RelationshipStatus.Blocked); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("{userId:guid}/block")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> BlockUser(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var relatedUser = await db.Accounts.FindAsync(userId); |  | ||||||
|         if (relatedUser is null) return NotFound("Account was not found."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var relationship = await rels.BlockAccount(currentUser, relatedUser); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     [HttpDelete("{userId:guid}/block")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<ActionResult<Relationship>> UnblockUser(Guid userId) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var relatedUser = await db.Accounts.FindAsync(userId); |  | ||||||
|         if (relatedUser is null) return NotFound("Account was not found."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var relationship = await rels.UnblockAccount(currentUser, relatedUser); |  | ||||||
|             return relationship; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException err) |  | ||||||
|         { |  | ||||||
|             return BadRequest(err.Message); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,207 +0,0 @@ | |||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| public class RelationshipService(AppDatabase db, ICacheService cache) |  | ||||||
| { |  | ||||||
|     private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; |  | ||||||
|     private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; |  | ||||||
|      |  | ||||||
|     public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId) |  | ||||||
|     { |  | ||||||
|         var count = await db.AccountRelationships |  | ||||||
|             .Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) || |  | ||||||
|                         (r.AccountId == relatedId && r.AccountId == accountId)) |  | ||||||
|             .CountAsync(); |  | ||||||
|         return count > 0; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Relationship?> GetRelationship( |  | ||||||
|         Guid accountId, |  | ||||||
|         Guid relatedId, |  | ||||||
|         RelationshipStatus? status = null, |  | ||||||
|         bool ignoreExpired = false |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var now = Instant.FromDateTimeUtc(DateTime.UtcNow); |  | ||||||
|         var queries = db.AccountRelationships.AsQueryable() |  | ||||||
|             .Where(r => r.AccountId == accountId && r.RelatedId == relatedId); |  | ||||||
|         if (!ignoreExpired) queries = queries.Where(r => r.ExpiredAt == null || r.ExpiredAt > now); |  | ||||||
|         if (status is not null) queries = queries.Where(r => r.Status == status); |  | ||||||
|         var relationship = await queries.FirstOrDefaultAsync(); |  | ||||||
|         return relationship; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status) |  | ||||||
|     { |  | ||||||
|         if (status == RelationshipStatus.Pending) |  | ||||||
|             throw new InvalidOperationException( |  | ||||||
|                 "Cannot create relationship with pending status, use SendFriendRequest instead."); |  | ||||||
|         if (await HasExistingRelationship(sender.Id, target.Id)) |  | ||||||
|             throw new InvalidOperationException("Found existing relationship between you and target user."); |  | ||||||
|  |  | ||||||
|         var relationship = new Relationship |  | ||||||
|         { |  | ||||||
|             AccountId = sender.Id, |  | ||||||
|             RelatedId = target.Id, |  | ||||||
|             Status = status |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.AccountRelationships.Add(relationship); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(sender.Id, target.Id); |  | ||||||
|  |  | ||||||
|         return relationship; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Relationship> BlockAccount(Account sender, Account target) |  | ||||||
|     { |  | ||||||
|         if (await HasExistingRelationship(sender.Id, target.Id)) |  | ||||||
|             return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); |  | ||||||
|         return await CreateRelationship(sender, target, RelationshipStatus.Blocked); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public async Task<Relationship> UnblockAccount(Account sender, Account target) |  | ||||||
|     { |  | ||||||
|         var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); |  | ||||||
|         if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); |  | ||||||
|         db.Remove(relationship); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(sender.Id, target.Id); |  | ||||||
|          |  | ||||||
|         return relationship; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Relationship> SendFriendRequest(Account sender, Account target) |  | ||||||
|     { |  | ||||||
|         if (await HasExistingRelationship(sender.Id, target.Id)) |  | ||||||
|             throw new InvalidOperationException("Found existing relationship between you and target user."); |  | ||||||
|  |  | ||||||
|         var relationship = new Relationship |  | ||||||
|         { |  | ||||||
|             AccountId = sender.Id, |  | ||||||
|             RelatedId = target.Id, |  | ||||||
|             Status = RelationshipStatus.Pending, |  | ||||||
|             ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7)) |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.AccountRelationships.Add(relationship); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return relationship; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public async Task DeleteFriendRequest(Guid accountId, Guid relatedId) |  | ||||||
|     { |  | ||||||
|         var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending); |  | ||||||
|         if (relationship is null) throw new ArgumentException("Friend request was not found."); |  | ||||||
|      |  | ||||||
|         await db.AccountRelationships |  | ||||||
|             .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) |  | ||||||
|             .ExecuteDeleteAsync(); |  | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public async Task<Relationship> AcceptFriendRelationship( |  | ||||||
|         Relationship relationship, |  | ||||||
|         RelationshipStatus status = RelationshipStatus.Friends |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         if (relationship.Status != RelationshipStatus.Pending) |  | ||||||
|             throw new ArgumentException("Cannot accept friend request that not in pending status."); |  | ||||||
|         if (status == RelationshipStatus.Pending) |  | ||||||
|             throw new ArgumentException("Cannot accept friend request by setting the new status to pending."); |  | ||||||
|  |  | ||||||
|         // Whatever the receiver decides to apply which status to the relationship, |  | ||||||
|         // the sender should always see the user as a friend since the sender ask for it |  | ||||||
|         relationship.Status = RelationshipStatus.Friends; |  | ||||||
|         relationship.ExpiredAt = null; |  | ||||||
|         db.Update(relationship); |  | ||||||
|  |  | ||||||
|         var relationshipBackward = new Relationship |  | ||||||
|         { |  | ||||||
|             AccountId = relationship.RelatedId, |  | ||||||
|             RelatedId = relationship.AccountId, |  | ||||||
|             Status = status |  | ||||||
|         }; |  | ||||||
|         db.AccountRelationships.Add(relationshipBackward); |  | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); |  | ||||||
|  |  | ||||||
|         return relationshipBackward; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status) |  | ||||||
|     { |  | ||||||
|         var relationship = await GetRelationship(accountId, relatedId); |  | ||||||
|         if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); |  | ||||||
|         if (relationship.Status == status) return relationship; |  | ||||||
|         relationship.Status = status; |  | ||||||
|         db.Update(relationship); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|          |  | ||||||
|         await PurgeRelationshipCache(accountId, relatedId); |  | ||||||
|          |  | ||||||
|         return relationship; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<List<Guid>> ListAccountFriends(Account account) |  | ||||||
|     { |  | ||||||
|         var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; |  | ||||||
|         var friends = await cache.GetAsync<List<Guid>>(cacheKey); |  | ||||||
|          |  | ||||||
|         if (friends == null) |  | ||||||
|         { |  | ||||||
|             friends = await db.AccountRelationships |  | ||||||
|                 .Where(r => r.RelatedId == account.Id) |  | ||||||
|                 .Where(r => r.Status == RelationshipStatus.Friends) |  | ||||||
|                 .Select(r => r.AccountId) |  | ||||||
|                 .ToListAsync(); |  | ||||||
|                  |  | ||||||
|             await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return friends ?? []; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public async Task<List<Guid>> ListAccountBlocked(Account account) |  | ||||||
|     { |  | ||||||
|         var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}"; |  | ||||||
|         var blocked = await cache.GetAsync<List<Guid>>(cacheKey); |  | ||||||
|          |  | ||||||
|         if (blocked == null) |  | ||||||
|         { |  | ||||||
|             blocked = await db.AccountRelationships |  | ||||||
|                 .Where(r => r.RelatedId == account.Id) |  | ||||||
|                 .Where(r => r.Status == RelationshipStatus.Blocked) |  | ||||||
|                 .Select(r => r.AccountId) |  | ||||||
|                 .ToListAsync(); |  | ||||||
|                  |  | ||||||
|             await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return blocked ?? []; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId, |  | ||||||
|         RelationshipStatus status = RelationshipStatus.Friends) |  | ||||||
|     { |  | ||||||
|         var relationship = await GetRelationship(accountId, relatedId, status); |  | ||||||
|         return relationship is not null; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId) |  | ||||||
|     { |  | ||||||
|         await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); |  | ||||||
|         await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); |  | ||||||
|         await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}"); |  | ||||||
|         await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}"); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,25 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Account; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// The verification info of a resource |  | ||||||
| /// stands, for it is really an individual or organization or a company in the real world. |  | ||||||
| /// Besides, it can also be use for mark parody or fake. |  | ||||||
| /// </summary> |  | ||||||
| public class VerificationMark |  | ||||||
| { |  | ||||||
|     public VerificationMarkType Type { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? Title { get; set; } |  | ||||||
|     [MaxLength(8192)] public string? Description { get; set; } |  | ||||||
|     [MaxLength(1024)] public string? VerifiedBy { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum VerificationMarkType |  | ||||||
| { |  | ||||||
|     Official, |  | ||||||
|     Individual, |  | ||||||
|     Organization, |  | ||||||
|     Government, |  | ||||||
|     Creator |  | ||||||
| } |  | ||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Text; | using NodaTime.Text; | ||||||
| @@ -45,7 +46,7 @@ public class ActivityController( | |||||||
|         var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>(); |         var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>(); | ||||||
|  |  | ||||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); |         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||||
|         return currentUserValue is not Account.Account currentUser |         return currentUserValue is not Account currentUser | ||||||
|             ? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet)) |             ? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet)) | ||||||
|             : Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet)); |             : Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet)); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| using DysonNetwork.Sphere.Account; | using DysonNetwork.Shared.Proto; | ||||||
| using DysonNetwork.Sphere.Connection.WebReader; | using DysonNetwork.Sphere.WebReader; | ||||||
| using DysonNetwork.Sphere.Discovery; | using DysonNetwork.Sphere.Discovery; | ||||||
| using DysonNetwork.Sphere.Post; | using DysonNetwork.Sphere.Post; | ||||||
| using DysonNetwork.Sphere.Publisher; | using DysonNetwork.Sphere.Publisher; | ||||||
| @@ -11,9 +11,10 @@ namespace DysonNetwork.Sphere.Activity; | |||||||
| public class ActivityService( | public class ActivityService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     PublisherService pub, |     PublisherService pub, | ||||||
|     RelationshipService rels, |  | ||||||
|     PostService ps, |     PostService ps, | ||||||
|     DiscoveryService ds) |     DiscoveryService ds, | ||||||
|  |     AccountService.AccountServiceClient accounts | ||||||
|  | ) | ||||||
| { | { | ||||||
|     private static double CalculateHotRank(Post.Post post, Instant now) |     private static double CalculateHotRank(Post.Post post, Instant now) | ||||||
|     { |     { | ||||||
| @@ -118,14 +119,16 @@ public class ActivityService( | |||||||
|     public async Task<List<Activity>> GetActivities( |     public async Task<List<Activity>> GetActivities( | ||||||
|         int take, |         int take, | ||||||
|         Instant? cursor, |         Instant? cursor, | ||||||
|         Account.Account currentUser, |         Account currentUser, | ||||||
|         string? filter = null, |         string? filter = null, | ||||||
|         HashSet<string>? debugInclude = null |         HashSet<string>? debugInclude = null | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var activities = new List<Activity>(); |         var activities = new List<Activity>(); | ||||||
|         var userFriends = await rels.ListAccountFriends(currentUser); |         var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest | ||||||
|         var userPublishers = await pub.GetUserPublishers(currentUser.Id); |             { AccountId = currentUser.Id }); | ||||||
|  |         var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); | ||||||
|  |         var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); | ||||||
|         debugInclude ??= []; |         debugInclude ??= []; | ||||||
|  |  | ||||||
|         if (string.IsNullOrEmpty(filter)) |         if (string.IsNullOrEmpty(filter)) | ||||||
| @@ -190,7 +193,7 @@ public class ActivityService( | |||||||
|         // Get publishers based on filter |         // Get publishers based on filter | ||||||
|         var filteredPublishers = filter switch |         var filteredPublishers = filter switch | ||||||
|         { |         { | ||||||
|             "subscriptions" => await pub.GetSubscribedPublishers(currentUser.Id), |             "subscriptions" => await pub.GetSubscribedPublishers(Guid.Parse(currentUser.Id)), | ||||||
|             "friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value) |             "friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value) | ||||||
|                 .DistinctBy(x => x.Id) |                 .DistinctBy(x => x.Id) | ||||||
|                 .ToList(), |                 .ToList(), | ||||||
| @@ -241,8 +244,7 @@ public class ActivityService( | |||||||
|             .ToList(); |             .ToList(); | ||||||
|  |  | ||||||
|         // Formatting data |         // Formatting data | ||||||
|         foreach (var post in rankedPosts) |         activities.AddRange(rankedPosts.Select(post => post.ToActivity())); | ||||||
|             activities.Add(post.ToActivity()); |  | ||||||
|  |  | ||||||
|         if (activities.Count == 0) |         if (activities.Count == 0) | ||||||
|             activities.Add(Activity.Empty()); |             activities.Add(Activity.Empty()); | ||||||
|   | |||||||
| @@ -1,21 +1,15 @@ | |||||||
| using System.Linq.Expressions; | using System.Linq.Expressions; | ||||||
| using System.Reflection; | using System.Reflection; | ||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using DysonNetwork.Sphere.Auth; |  | ||||||
| using DysonNetwork.Sphere.Chat; | using DysonNetwork.Sphere.Chat; | ||||||
| using DysonNetwork.Sphere.Developer; | using DysonNetwork.Sphere.Developer; | ||||||
| using DysonNetwork.Sphere.Permission; |  | ||||||
| using DysonNetwork.Sphere.Post; | using DysonNetwork.Sphere.Post; | ||||||
| using DysonNetwork.Sphere.Publisher; | using DysonNetwork.Sphere.Publisher; | ||||||
| using DysonNetwork.Sphere.Realm; | using DysonNetwork.Sphere.Realm; | ||||||
| using DysonNetwork.Sphere.Sticker; | using DysonNetwork.Sphere.Sticker; | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using DysonNetwork.Sphere.Wallet; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.EntityFrameworkCore.Design; | using Microsoft.EntityFrameworkCore.Design; | ||||||
| using Microsoft.EntityFrameworkCore.Query; | using Microsoft.EntityFrameworkCore.Query; | ||||||
| using NodaTime; | using NodaTime; | ||||||
| using Npgsql; |  | ||||||
| using Quartz; | using Quartz; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere; | namespace DysonNetwork.Sphere; | ||||||
| @@ -37,31 +31,6 @@ public class AppDatabase( | |||||||
|     IConfiguration configuration |     IConfiguration configuration | ||||||
| ) : DbContext(options) | ) : 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.Account> Accounts { get; set; } |  | ||||||
|     public DbSet<AccountConnection> AccountConnections { get; set; } |  | ||||||
|     public DbSet<Profile> AccountProfiles { get; set; } |  | ||||||
|     public DbSet<AccountContact> AccountContacts { get; set; } |  | ||||||
|     public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } |  | ||||||
|     public DbSet<Relationship> AccountRelationships { get; set; } |  | ||||||
|     public DbSet<Status> AccountStatuses { get; set; } |  | ||||||
|     public DbSet<CheckInResult> AccountCheckInResults { get; set; } |  | ||||||
|     public DbSet<Notification> Notifications { get; set; } |  | ||||||
|     public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; } |  | ||||||
|     public DbSet<Badge> Badges { get; set; } |  | ||||||
|     public DbSet<ActionLog> ActionLogs { get; set; } |  | ||||||
|     public DbSet<AbuseReport> AbuseReports { get; set; } |  | ||||||
|  |  | ||||||
|     public DbSet<Session> AuthSessions { get; set; } |  | ||||||
|     public DbSet<Challenge> AuthChallenges { get; set; } |  | ||||||
|  |  | ||||||
|     public DbSet<CloudFile> Files { get; set; } |  | ||||||
|     public DbSet<CloudFileReference> FileReferences { get; set; } |  | ||||||
|  |  | ||||||
|     public DbSet<Publisher.Publisher> Publishers { get; set; } |     public DbSet<Publisher.Publisher> Publishers { get; set; } | ||||||
|     public DbSet<PublisherMember> PublisherMembers { get; set; } |     public DbSet<PublisherMember> PublisherMembers { get; set; } | ||||||
|     public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; } |     public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; } | ||||||
| @@ -87,18 +56,11 @@ public class AppDatabase( | |||||||
|     public DbSet<Sticker.Sticker> Stickers { get; set; } |     public DbSet<Sticker.Sticker> Stickers { get; set; } | ||||||
|     public DbSet<StickerPack> StickerPacks { get; set; } |     public DbSet<StickerPack> StickerPacks { get; set; } | ||||||
|  |  | ||||||
|     public DbSet<Wallet.Wallet> Wallets { get; set; } |  | ||||||
|     public DbSet<WalletPocket> WalletPockets { get; set; } |  | ||||||
|     public DbSet<Order> PaymentOrders { get; set; } |  | ||||||
|     public DbSet<Transaction> PaymentTransactions { get; set; } |  | ||||||
|  |  | ||||||
|     public DbSet<CustomApp> CustomApps { get; set; } |     public DbSet<CustomApp> CustomApps { get; set; } | ||||||
|     public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } |     public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } | ||||||
|  |  | ||||||
|     public DbSet<Subscription> WalletSubscriptions { get; set; } |     public DbSet<WebReader.WebArticle> WebArticles { get; set; } | ||||||
|     public DbSet<Coupon> WalletCoupons { get; set; } |     public DbSet<WebReader.WebFeed> WebFeeds { get; set; } | ||||||
|     public DbSet<Connection.WebReader.WebArticle> WebArticles { get; set; } |  | ||||||
|     public DbSet<Connection.WebReader.WebFeed> WebFeeds { get; set; } |  | ||||||
|  |  | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|     { |     { | ||||||
| @@ -111,38 +73,6 @@ public class AppDatabase( | |||||||
|                 .UseNodaTime() |                 .UseNodaTime() | ||||||
|         ).UseSnakeCaseNamingConvention(); |         ).UseSnakeCaseNamingConvention(); | ||||||
|  |  | ||||||
|         optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => |  | ||||||
|         { |  | ||||||
|             var defaultPermissionGroup = await context.Set<PermissionGroup>() |  | ||||||
|                 .FirstOrDefaultAsync(g => g.Key == "default", cancellationToken); |  | ||||||
|             if (defaultPermissionGroup is null) |  | ||||||
|             { |  | ||||||
|                 context.Set<PermissionGroup>().Add(new PermissionGroup |  | ||||||
|                 { |  | ||||||
|                     Key = "default", |  | ||||||
|                     Nodes = new List<string> |  | ||||||
|                         { |  | ||||||
|                             "posts.create", |  | ||||||
|                             "posts.react", |  | ||||||
|                             "publishers.create", |  | ||||||
|                             "files.create", |  | ||||||
|                             "chat.create", |  | ||||||
|                             "chat.messages.create", |  | ||||||
|                             "chat.realtime.create", |  | ||||||
|                             "accounts.statuses.create", |  | ||||||
|                             "accounts.statuses.update", |  | ||||||
|                             "stickers.packs.create", |  | ||||||
|                             "stickers.create" |  | ||||||
|                         }.Select(permission => |  | ||||||
|                             PermissionService.NewPermissionNode("group:default", "global", permission, true)) |  | ||||||
|                         .ToList() |  | ||||||
|                 }); |  | ||||||
|                 await context.SaveChangesAsync(cancellationToken); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         optionsBuilder.UseSeeding((context, _) => {}); |  | ||||||
|  |  | ||||||
|         base.OnConfiguring(optionsBuilder); |         base.OnConfiguring(optionsBuilder); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -150,25 +80,6 @@ public class AppDatabase( | |||||||
|     { |     { | ||||||
|         base.OnModelCreating(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); |  | ||||||
|  |  | ||||||
|         modelBuilder.Entity<PublisherMember>() |         modelBuilder.Entity<PublisherMember>() | ||||||
|             .HasKey(pm => new { pm.PublisherId, pm.AccountId }); |             .HasKey(pm => new { pm.PublisherId, pm.AccountId }); | ||||||
|         modelBuilder.Entity<PublisherMember>() |         modelBuilder.Entity<PublisherMember>() | ||||||
| @@ -176,21 +87,11 @@ public class AppDatabase( | |||||||
|             .WithMany(p => p.Members) |             .WithMany(p => p.Members) | ||||||
|             .HasForeignKey(pm => pm.PublisherId) |             .HasForeignKey(pm => pm.PublisherId) | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |             .OnDelete(DeleteBehavior.Cascade); | ||||||
|         modelBuilder.Entity<PublisherMember>() |  | ||||||
|             .HasOne(pm => pm.Account) |  | ||||||
|             .WithMany() |  | ||||||
|             .HasForeignKey(pm => pm.AccountId) |  | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |  | ||||||
|         modelBuilder.Entity<PublisherSubscription>() |         modelBuilder.Entity<PublisherSubscription>() | ||||||
|             .HasOne(ps => ps.Publisher) |             .HasOne(ps => ps.Publisher) | ||||||
|             .WithMany(p => p.Subscriptions) |             .WithMany(p => p.Subscriptions) | ||||||
|             .HasForeignKey(ps => ps.PublisherId) |             .HasForeignKey(ps => ps.PublisherId) | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |             .OnDelete(DeleteBehavior.Cascade); | ||||||
|         modelBuilder.Entity<PublisherSubscription>() |  | ||||||
|             .HasOne(ps => ps.Account) |  | ||||||
|             .WithMany() |  | ||||||
|             .HasForeignKey(ps => ps.AccountId) |  | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |  | ||||||
|  |  | ||||||
|         modelBuilder.Entity<Post.Post>() |         modelBuilder.Entity<Post.Post>() | ||||||
|             .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) |             .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) | ||||||
| @@ -237,11 +138,6 @@ public class AppDatabase( | |||||||
|             .WithMany(p => p.Members) |             .WithMany(p => p.Members) | ||||||
|             .HasForeignKey(pm => pm.RealmId) |             .HasForeignKey(pm => pm.RealmId) | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |             .OnDelete(DeleteBehavior.Cascade); | ||||||
|         modelBuilder.Entity<RealmMember>() |  | ||||||
|             .HasOne(pm => pm.Account) |  | ||||||
|             .WithMany() |  | ||||||
|             .HasForeignKey(pm => pm.AccountId) |  | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |  | ||||||
|  |  | ||||||
|         modelBuilder.Entity<RealmTag>() |         modelBuilder.Entity<RealmTag>() | ||||||
|             .HasKey(rt => new { rt.RealmId, rt.TagId }); |             .HasKey(rt => new { rt.RealmId, rt.TagId }); | ||||||
| @@ -265,11 +161,6 @@ public class AppDatabase( | |||||||
|             .WithMany(p => p.Members) |             .WithMany(p => p.Members) | ||||||
|             .HasForeignKey(pm => pm.ChatRoomId) |             .HasForeignKey(pm => pm.ChatRoomId) | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |             .OnDelete(DeleteBehavior.Cascade); | ||||||
|         modelBuilder.Entity<ChatMember>() |  | ||||||
|             .HasOne(pm => pm.Account) |  | ||||||
|             .WithMany() |  | ||||||
|             .HasForeignKey(pm => pm.AccountId) |  | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |  | ||||||
|         modelBuilder.Entity<Message>() |         modelBuilder.Entity<Message>() | ||||||
|             .HasOne(m => m.ForwardedMessage) |             .HasOne(m => m.ForwardedMessage) | ||||||
|             .WithMany() |             .WithMany() | ||||||
| @@ -291,11 +182,10 @@ public class AppDatabase( | |||||||
|             .HasForeignKey(m => m.SenderId) |             .HasForeignKey(m => m.SenderId) | ||||||
|             .OnDelete(DeleteBehavior.Cascade); |             .OnDelete(DeleteBehavior.Cascade); | ||||||
|  |  | ||||||
|         modelBuilder.Entity<Connection.WebReader.WebFeed>() |         modelBuilder.Entity<WebReader.WebFeed>() | ||||||
|             .HasIndex(f => f.Url) |             .HasIndex(f => f.Url) | ||||||
|             .IsUnique(); |             .IsUnique(); | ||||||
|  |         modelBuilder.Entity<WebReader.WebArticle>() | ||||||
|         modelBuilder.Entity<Connection.WebReader.WebArticle>() |  | ||||||
|             .HasIndex(a => a.Url) |             .HasIndex(a => a.Url) | ||||||
|             .IsUnique(); |             .IsUnique(); | ||||||
|  |  | ||||||
| @@ -354,19 +244,6 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin | |||||||
|     { |     { | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |         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); |  | ||||||
|         // Expired permission group members |  | ||||||
|         affectedRows = await db.PermissionGroupMembers |  | ||||||
|             .Where(x => x.ExpiredAt != null && x.ExpiredAt <= now) |  | ||||||
|             .ExecuteDeleteAsync(); |  | ||||||
|         logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows); |  | ||||||
|  |  | ||||||
|         logger.LogInformation("Deleting soft-deleted records..."); |         logger.LogInformation("Deleting soft-deleted records..."); | ||||||
|  |  | ||||||
|         var threshold = now - Duration.FromDays(7); |         var threshold = now - Duration.FromDays(7); | ||||||
|   | |||||||
| @@ -1,279 +0,0 @@ | |||||||
| using System.IdentityModel.Tokens.Jwt; |  | ||||||
| using System.Security.Claims; |  | ||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text.Encodings.Web; |  | ||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using DysonNetwork.Sphere.Storage.Handlers; |  | ||||||
| using Microsoft.AspNetCore.Authentication; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.Extensions.Options; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
| using NodaTime; |  | ||||||
| using System.Text; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Controllers; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; |  | ||||||
| using SystemClock = NodaTime.SystemClock; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth; |  | ||||||
|  |  | ||||||
| public static class AuthConstants |  | ||||||
| { |  | ||||||
|     public const string SchemeName = "DysonToken"; |  | ||||||
|     public const string TokenQueryParamName = "tk"; |  | ||||||
|     public const string CookieTokenName = "AuthToken"; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public enum TokenType |  | ||||||
| { |  | ||||||
|     AuthKey, |  | ||||||
|     ApiKey, |  | ||||||
|     OidcKey, |  | ||||||
|     Unknown |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class TokenInfo |  | ||||||
| { |  | ||||||
|     public string Token { get; set; } = string.Empty; |  | ||||||
|     public TokenType Type { get; set; } = TokenType.Unknown; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class DysonTokenAuthOptions : AuthenticationSchemeOptions; |  | ||||||
|  |  | ||||||
| public class DysonTokenAuthHandler( |  | ||||||
|     IOptionsMonitor<DysonTokenAuthOptions> options, |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     ILoggerFactory logger, |  | ||||||
|     UrlEncoder encoder, |  | ||||||
|     AppDatabase database, |  | ||||||
|     OidcProviderService oidc, |  | ||||||
|     ICacheService cache, |  | ||||||
|     FlushBufferService fbs |  | ||||||
| ) |  | ||||||
|     : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder) |  | ||||||
| { |  | ||||||
|     public const string AuthCachePrefix = "auth:"; |  | ||||||
|  |  | ||||||
|     protected override async Task<AuthenticateResult> HandleAuthenticateAsync() |  | ||||||
|     { |  | ||||||
|         var tokenInfo = _ExtractToken(Request); |  | ||||||
|  |  | ||||||
|         if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token)) |  | ||||||
|             return AuthenticateResult.Fail("No token was provided."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|  |  | ||||||
|             // Validate token and extract session ID |  | ||||||
|             if (!ValidateToken(tokenInfo.Token, out var sessionId)) |  | ||||||
|                 return AuthenticateResult.Fail("Invalid token."); |  | ||||||
|  |  | ||||||
|             // Try to get session from cache first |  | ||||||
|             var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}"); |  | ||||||
|  |  | ||||||
|             // If not in cache, load from database |  | ||||||
|             if (session is null) |  | ||||||
|             { |  | ||||||
|                 session = await database.AuthSessions |  | ||||||
|                     .Where(e => e.Id == sessionId) |  | ||||||
|                     .Include(e => e.Challenge) |  | ||||||
|                     .Include(e => e.Account) |  | ||||||
|                     .ThenInclude(e => e.Profile) |  | ||||||
|                     .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|                 if (session is not null) |  | ||||||
|                 { |  | ||||||
|                     // Store in cache for future requests |  | ||||||
|                     await cache.SetWithGroupsAsync( |  | ||||||
|                         $"auth:{sessionId}", |  | ||||||
|                         session, |  | ||||||
|                         [$"{AccountService.AccountCachePrefix}{session.Account.Id}"], |  | ||||||
|                         TimeSpan.FromHours(1) |  | ||||||
|                     ); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Check if the session exists |  | ||||||
|             if (session == null) |  | ||||||
|                 return AuthenticateResult.Fail("Session not found."); |  | ||||||
|  |  | ||||||
|             // Check if the session is expired |  | ||||||
|             if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now) |  | ||||||
|                 return AuthenticateResult.Fail("Session expired."); |  | ||||||
|  |  | ||||||
|             // Store user and session in the HttpContext.Items for easy access in controllers |  | ||||||
|             Context.Items["CurrentUser"] = session.Account; |  | ||||||
|             Context.Items["CurrentSession"] = session; |  | ||||||
|             Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString(); |  | ||||||
|  |  | ||||||
|             // Create claims from the session |  | ||||||
|             var claims = new List<Claim> |  | ||||||
|             { |  | ||||||
|                 new("user_id", session.Account.Id.ToString()), |  | ||||||
|                 new("session_id", session.Id.ToString()), |  | ||||||
|                 new("token_type", tokenInfo.Type.ToString()) |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             // Add scopes as claims |  | ||||||
|             session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); |  | ||||||
|  |  | ||||||
|             // Add superuser claim if applicable |  | ||||||
|             if (session.Account.IsSuperuser) |  | ||||||
|                 claims.Add(new Claim("is_superuser", "1")); |  | ||||||
|  |  | ||||||
|             // Create the identity and principal |  | ||||||
|             var identity = new ClaimsIdentity(claims, AuthConstants.SchemeName); |  | ||||||
|             var principal = new ClaimsPrincipal(identity); |  | ||||||
|  |  | ||||||
|             var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName); |  | ||||||
|  |  | ||||||
|             var lastInfo = new LastActiveInfo |  | ||||||
|             { |  | ||||||
|                 Account = session.Account, |  | ||||||
|                 Session = session, |  | ||||||
|                 SeenAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|             }; |  | ||||||
|             fbs.Enqueue(lastInfo); |  | ||||||
|  |  | ||||||
|             return AuthenticateResult.Success(ticket); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return AuthenticateResult.Fail($"Authentication failed: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private bool ValidateToken(string token, out Guid sessionId) |  | ||||||
|     { |  | ||||||
|         sessionId = Guid.Empty; |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var parts = token.Split('.'); |  | ||||||
|  |  | ||||||
|             switch (parts.Length) |  | ||||||
|             { |  | ||||||
|                 // Handle JWT tokens (3 parts) |  | ||||||
|                 case 3: |  | ||||||
|                 { |  | ||||||
|                     var (isValid, jwtResult) = oidc.ValidateToken(token); |  | ||||||
|                     if (!isValid) return false; |  | ||||||
|                     var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value; |  | ||||||
|                     if (jti is null) return false; |  | ||||||
|  |  | ||||||
|                     return Guid.TryParse(jti, out sessionId); |  | ||||||
|                 } |  | ||||||
|                 // Handle compact tokens (2 parts) |  | ||||||
|                 case 2: |  | ||||||
|                     // Original compact token validation logic |  | ||||||
|                     try |  | ||||||
|                     { |  | ||||||
|                         // Decode the payload |  | ||||||
|                         var payloadBytes = Base64UrlDecode(parts[0]); |  | ||||||
|  |  | ||||||
|                         // Extract session ID |  | ||||||
|                         sessionId = new Guid(payloadBytes); |  | ||||||
|  |  | ||||||
|                         // Load public key for verification |  | ||||||
|                         var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!); |  | ||||||
|                         using var rsa = RSA.Create(); |  | ||||||
|                         rsa.ImportFromPem(publicKeyPem); |  | ||||||
|  |  | ||||||
|                         // Verify signature |  | ||||||
|                         var signature = Base64UrlDecode(parts[1]); |  | ||||||
|                         return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); |  | ||||||
|                     } |  | ||||||
|                     catch |  | ||||||
|                     { |  | ||||||
|                         return false; |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     break; |  | ||||||
|                 default: |  | ||||||
|                     return false; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             Logger.LogWarning(ex, "Token validation failed"); |  | ||||||
|             return false; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static byte[] Base64UrlDecode(string base64Url) |  | ||||||
|     { |  | ||||||
|         var padded = base64Url |  | ||||||
|             .Replace('-', '+') |  | ||||||
|             .Replace('_', '/'); |  | ||||||
|  |  | ||||||
|         switch (padded.Length % 4) |  | ||||||
|         { |  | ||||||
|             case 2: padded += "=="; break; |  | ||||||
|             case 3: padded += "="; break; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Convert.FromBase64String(padded); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private TokenInfo? _ExtractToken(HttpRequest request) |  | ||||||
|     { |  | ||||||
|         // Check for token in query parameters |  | ||||||
|         if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken)) |  | ||||||
|         { |  | ||||||
|             return new TokenInfo |  | ||||||
|             { |  | ||||||
|                 Token = queryToken.ToString(), |  | ||||||
|                 Type = TokenType.AuthKey |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|         // Check for token in Authorization header |  | ||||||
|         var authHeader = request.Headers.Authorization.ToString(); |  | ||||||
|         if (!string.IsNullOrEmpty(authHeader)) |  | ||||||
|         { |  | ||||||
|             if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) |  | ||||||
|             { |  | ||||||
|                 var token = authHeader["Bearer ".Length..].Trim(); |  | ||||||
|                 var parts = token.Split('.'); |  | ||||||
|                  |  | ||||||
|                 return new TokenInfo |  | ||||||
|                 { |  | ||||||
|                     Token = token, |  | ||||||
|                     Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey |  | ||||||
|                 }; |  | ||||||
|             } |  | ||||||
|             else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase)) |  | ||||||
|             { |  | ||||||
|                 return new TokenInfo |  | ||||||
|                 { |  | ||||||
|                     Token = authHeader["AtField ".Length..].Trim(), |  | ||||||
|                     Type = TokenType.AuthKey |  | ||||||
|                 }; |  | ||||||
|             } |  | ||||||
|             else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase)) |  | ||||||
|             { |  | ||||||
|                 return new TokenInfo |  | ||||||
|                 { |  | ||||||
|                     Token = authHeader["AkField ".Length..].Trim(), |  | ||||||
|                     Type = TokenType.ApiKey |  | ||||||
|                 }; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Check for token in cookies |  | ||||||
|         if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken)) |  | ||||||
|         { |  | ||||||
|             return new TokenInfo |  | ||||||
|             { |  | ||||||
|                 Token = cookieToken, |  | ||||||
|                 Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|         return null; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,269 +0,0 @@ | |||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using NodaTime; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using System.IdentityModel.Tokens.Jwt; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Connection; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/auth")] |  | ||||||
| public class AuthController( |  | ||||||
|     AppDatabase db, |  | ||||||
|     AccountService accounts, |  | ||||||
|     AuthService auth, |  | ||||||
|     GeoIpService geo, |  | ||||||
|     ActionLogService als |  | ||||||
| ) : ControllerBase |  | ||||||
| { |  | ||||||
|     public class ChallengeRequest |  | ||||||
|     { |  | ||||||
|         [Required] public ChallengePlatform Platform { get; set; } |  | ||||||
|         [Required] [MaxLength(256)] public string Account { get; set; } = null!; |  | ||||||
|         [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!; |  | ||||||
|         public List<string> Audiences { get; set; } = new(); |  | ||||||
|         public List<string> Scopes { get; set; } = new(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("challenge")] |  | ||||||
|     public async Task<ActionResult<Challenge>> StartChallenge([FromBody] ChallengeRequest request) |  | ||||||
|     { |  | ||||||
|         var account = await accounts.LookupAccount(request.Account); |  | ||||||
|         if (account is null) return NotFound("Account was not found."); |  | ||||||
|  |  | ||||||
|         var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); |  | ||||||
|         var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); |  | ||||||
|  |  | ||||||
|         var now = Instant.FromDateTimeUtc(DateTime.UtcNow); |  | ||||||
|  |  | ||||||
|         // Trying to pick up challenges from the same IP address and user agent |  | ||||||
|         var existingChallenge = await db.AuthChallenges |  | ||||||
|             .Where(e => e.Account == account) |  | ||||||
|             .Where(e => e.IpAddress == ipAddress) |  | ||||||
|             .Where(e => e.UserAgent == userAgent) |  | ||||||
|             .Where(e => e.StepRemain > 0) |  | ||||||
|             .Where(e => e.ExpiredAt != null && now < e.ExpiredAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (existingChallenge is not null) return existingChallenge; |  | ||||||
|  |  | ||||||
|         var challenge = new Challenge |  | ||||||
|         { |  | ||||||
|             ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), |  | ||||||
|             StepTotal = await auth.DetectChallengeRisk(Request, account), |  | ||||||
|             Platform = request.Platform, |  | ||||||
|             Audiences = request.Audiences, |  | ||||||
|             Scopes = request.Scopes, |  | ||||||
|             IpAddress = ipAddress, |  | ||||||
|             UserAgent = userAgent, |  | ||||||
|             Location = geo.GetPointFromIp(ipAddress), |  | ||||||
|             DeviceId = request.DeviceId, |  | ||||||
|             AccountId = account.Id |  | ||||||
|         }.Normalize(); |  | ||||||
|  |  | ||||||
|         await db.AuthChallenges.AddAsync(challenge); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt, |  | ||||||
|             new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         return challenge; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("challenge/{id:guid}")] |  | ||||||
|     public async Task<ActionResult<Challenge>> GetChallenge([FromRoute] Guid id) |  | ||||||
|     { |  | ||||||
|         var challenge = await db.AuthChallenges |  | ||||||
|             .Include(e => e.Account) |  | ||||||
|             .ThenInclude(e => e.Profile) |  | ||||||
|             .FirstOrDefaultAsync(e => e.Id == id); |  | ||||||
|  |  | ||||||
|         return challenge is null  |  | ||||||
|             ? NotFound("Auth challenge was not found.")  |  | ||||||
|             : challenge; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("challenge/{id:guid}/factors")] |  | ||||||
|     public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id) |  | ||||||
|     { |  | ||||||
|         var challenge = await db.AuthChallenges |  | ||||||
|             .Include(e => e.Account) |  | ||||||
|             .Include(e => e.Account.AuthFactors) |  | ||||||
|             .Where(e => e.Id == id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         return challenge is null |  | ||||||
|             ? NotFound("Auth challenge was not found.") |  | ||||||
|             : challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")] |  | ||||||
|     public async Task<ActionResult> RequestFactorCode( |  | ||||||
|         [FromRoute] Guid id, |  | ||||||
|         [FromRoute] Guid factorId, |  | ||||||
|         [FromBody] string? hint |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var challenge = await db.AuthChallenges |  | ||||||
|             .Include(e => e.Account) |  | ||||||
|             .Where(e => e.Id == id).FirstOrDefaultAsync(); |  | ||||||
|         if (challenge is null) return NotFound("Auth challenge was not found."); |  | ||||||
|         var factor = await db.AccountAuthFactors |  | ||||||
|             .Where(e => e.Id == factorId) |  | ||||||
|             .Where(e => e.Account == challenge.Account).FirstOrDefaultAsync(); |  | ||||||
|         if (factor is null) return NotFound("Auth factor was not found."); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await accounts.SendFactorCode(challenge.Account, factor, hint); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest(ex.Message); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class PerformChallengeRequest |  | ||||||
|     { |  | ||||||
|         [Required] public Guid FactorId { get; set; } |  | ||||||
|         [Required] public string Password { get; set; } = string.Empty; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPatch("challenge/{id:guid}")] |  | ||||||
|     public async Task<ActionResult<Challenge>> DoChallenge( |  | ||||||
|         [FromRoute] Guid id, |  | ||||||
|         [FromBody] PerformChallengeRequest request |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id); |  | ||||||
|         if (challenge is null) return NotFound("Auth challenge was not found."); |  | ||||||
|  |  | ||||||
|         var factor = await db.AccountAuthFactors.FindAsync(request.FactorId); |  | ||||||
|         if (factor is null) return NotFound("Auth factor was not found."); |  | ||||||
|         if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled."); |  | ||||||
|         if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy."); |  | ||||||
|  |  | ||||||
|         if (challenge.StepRemain == 0) return challenge; |  | ||||||
|         if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) |  | ||||||
|             return BadRequest(); |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             if (await accounts.VerifyFactorCode(factor, request.Password)) |  | ||||||
|             { |  | ||||||
|                 challenge.StepRemain -= factor.Trustworthy; |  | ||||||
|                 challenge.StepRemain = Math.Max(0, challenge.StepRemain); |  | ||||||
|                 challenge.BlacklistFactors.Add(factor.Id); |  | ||||||
|                 db.Update(challenge); |  | ||||||
|                 als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, |  | ||||||
|                     new Dictionary<string, object> |  | ||||||
|                     { |  | ||||||
|                         { "challenge_id", challenge.Id }, |  | ||||||
|                         { "factor_id", factor.Id } |  | ||||||
|                     }, Request, challenge.Account |  | ||||||
|                 ); |  | ||||||
|             } |  | ||||||
|             else |  | ||||||
|             { |  | ||||||
|                 throw new ArgumentException("Invalid password."); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             challenge.FailedAttempts++; |  | ||||||
|             db.Update(challenge); |  | ||||||
|             als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, |  | ||||||
|                 new Dictionary<string, object> |  | ||||||
|                 { |  | ||||||
|                     { "challenge_id", challenge.Id }, |  | ||||||
|                     { "factor_id", factor.Id } |  | ||||||
|                 }, Request, challenge.Account |  | ||||||
|             ); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|             return BadRequest("Invalid password."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (challenge.StepRemain == 0) |  | ||||||
|         { |  | ||||||
|             als.CreateActionLogFromRequest(ActionLogType.NewLogin, |  | ||||||
|                 new Dictionary<string, object> |  | ||||||
|                 { |  | ||||||
|                     { "challenge_id", challenge.Id }, |  | ||||||
|                     { "account_id", challenge.AccountId } |  | ||||||
|                 }, Request, challenge.Account |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|         return challenge; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class TokenExchangeRequest |  | ||||||
|     { |  | ||||||
|         public string GrantType { get; set; } = string.Empty; |  | ||||||
|         public string? RefreshToken { get; set; } |  | ||||||
|         public string? Code { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class TokenExchangeResponse |  | ||||||
|     { |  | ||||||
|         public string Token { get; set; } = string.Empty; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("token")] |  | ||||||
|     public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request) |  | ||||||
|     { |  | ||||||
|         switch (request.GrantType) |  | ||||||
|         { |  | ||||||
|             case "authorization_code": |  | ||||||
|                 var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty; |  | ||||||
|                 if (code == Guid.Empty) |  | ||||||
|                     return BadRequest("Invalid or missing authorization code."); |  | ||||||
|                 var challenge = await db.AuthChallenges |  | ||||||
|                     .Include(e => e.Account) |  | ||||||
|                     .Where(e => e.Id == code) |  | ||||||
|                     .FirstOrDefaultAsync(); |  | ||||||
|                 if (challenge is null) |  | ||||||
|                     return BadRequest("Authorization code not found or expired."); |  | ||||||
|                 if (challenge.StepRemain != 0) |  | ||||||
|                     return BadRequest("Challenge not yet completed."); |  | ||||||
|  |  | ||||||
|                 var session = await db.AuthSessions |  | ||||||
|                     .Where(e => e.Challenge == challenge) |  | ||||||
|                     .FirstOrDefaultAsync(); |  | ||||||
|                 if (session is not null) |  | ||||||
|                     return BadRequest("Session already exists for this challenge."); |  | ||||||
|  |  | ||||||
|                 session = new Session |  | ||||||
|                 { |  | ||||||
|                     LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), |  | ||||||
|                     ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), |  | ||||||
|                     Account = challenge.Account, |  | ||||||
|                     Challenge = challenge, |  | ||||||
|                 }; |  | ||||||
|  |  | ||||||
|                 db.AuthSessions.Add(session); |  | ||||||
|                 await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|                 var tk = auth.CreateToken(session); |  | ||||||
|                 return Ok(new TokenExchangeResponse { Token = tk }); |  | ||||||
|             case "refresh_token": |  | ||||||
|             // Since we no longer need the refresh token |  | ||||||
|             // This case is blank for now, thinking to mock it if the OIDC standard requires it |  | ||||||
|             default: |  | ||||||
|                 return BadRequest("Unsupported grant type."); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("captcha")] |  | ||||||
|     public async Task<ActionResult> ValidateCaptcha([FromBody] string token) |  | ||||||
|     { |  | ||||||
|         var result = await auth.ValidateCaptcha(token); |  | ||||||
|         return result ? Ok() : BadRequest(); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,304 +0,0 @@ | |||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth; |  | ||||||
|  |  | ||||||
| public class AuthService( |  | ||||||
|     AppDatabase db, |  | ||||||
|     IConfiguration config, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     IHttpContextAccessor httpContextAccessor, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
| { |  | ||||||
|     private HttpContext HttpContext => httpContextAccessor.HttpContext!; |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Detect the risk of the current request to login |  | ||||||
|     /// and returns the required steps to login. |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="request">The request context</param> |  | ||||||
|     /// <param name="account">The account to login</param> |  | ||||||
|     /// <returns>The required steps to login</returns> |  | ||||||
|     public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account) |  | ||||||
|     { |  | ||||||
|         // 1) Find out how many authentication factors the account has enabled. |  | ||||||
|         var maxSteps = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == account.Id) |  | ||||||
|             .Where(f => f.EnabledAt != null) |  | ||||||
|             .CountAsync(); |  | ||||||
|  |  | ||||||
|         // We’ll accumulate a “risk score” based on various factors. |  | ||||||
|         // Then we can decide how many total steps are required for the challenge. |  | ||||||
|         var riskScore = 0; |  | ||||||
|  |  | ||||||
|         // 2) Get the remote IP address from the request (if any). |  | ||||||
|         var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(); |  | ||||||
|         var lastActiveInfo = await db.AuthSessions |  | ||||||
|             .OrderByDescending(s => s.LastGrantedAt) |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.AccountId == account.Id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|  |  | ||||||
|         // Example check: if IP is missing or in an unusual range, increase the risk. |  | ||||||
|         // (This is just a placeholder; in reality, you’d integrate with GeoIpService or a custom check.) |  | ||||||
|         if (string.IsNullOrWhiteSpace(ipAddress)) |  | ||||||
|             riskScore += 1; |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && |  | ||||||
|                 !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) |  | ||||||
|                 riskScore += 1; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // 3) (Optional) Check how recent the last login was. |  | ||||||
|         // If it was a long time ago, the risk might be higher. |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null |  | ||||||
|             ? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays |  | ||||||
|             : double.MaxValue; |  | ||||||
|         if (daysSinceLastActive > 30) |  | ||||||
|             riskScore += 1; |  | ||||||
|  |  | ||||||
|         // 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score. |  | ||||||
|         const int totalRiskScore = 3; |  | ||||||
|         var totalRequiredSteps = (int)Math.Round((float)maxSteps * riskScore / totalRiskScore); |  | ||||||
|         // Clamp the steps |  | ||||||
|         totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1); |  | ||||||
|  |  | ||||||
|         return totalRequiredSteps; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) |  | ||||||
|     { |  | ||||||
|         var challenge = new Challenge |  | ||||||
|         { |  | ||||||
|             AccountId = account.Id, |  | ||||||
|             IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), |  | ||||||
|             UserAgent = HttpContext.Request.Headers.UserAgent, |  | ||||||
|             StepRemain = 1, |  | ||||||
|             StepTotal = 1, |  | ||||||
|             Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var session = new Session |  | ||||||
|         { |  | ||||||
|             AccountId = account.Id, |  | ||||||
|             CreatedAt = time, |  | ||||||
|             LastGrantedAt = time, |  | ||||||
|             Challenge = challenge, |  | ||||||
|             AppId = customAppId |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.AuthChallenges.Add(challenge); |  | ||||||
|         db.AuthSessions.Add(session); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return session; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> ValidateCaptcha(string token) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrWhiteSpace(token)) return false; |  | ||||||
|  |  | ||||||
|         var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); |  | ||||||
|         var apiSecret = config.GetSection("Captcha")["ApiSecret"]; |  | ||||||
|  |  | ||||||
|         var client = httpClientFactory.CreateClient(); |  | ||||||
|  |  | ||||||
|         var jsonOpts = new JsonSerializerOptions |  | ||||||
|         { |  | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |  | ||||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         switch (provider) |  | ||||||
|         { |  | ||||||
|             case "cloudflare": |  | ||||||
|                 var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, |  | ||||||
|                     "application/x-www-form-urlencoded"); |  | ||||||
|                 var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", |  | ||||||
|                     content); |  | ||||||
|                 response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|                 var json = await response.Content.ReadAsStringAsync(); |  | ||||||
|                 var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts); |  | ||||||
|  |  | ||||||
|                 return result?.Success == true; |  | ||||||
|             case "google": |  | ||||||
|                 content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, |  | ||||||
|                     "application/x-www-form-urlencoded"); |  | ||||||
|                 response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content); |  | ||||||
|                 response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|                 json = await response.Content.ReadAsStringAsync(); |  | ||||||
|                 result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts); |  | ||||||
|  |  | ||||||
|                 return result?.Success == true; |  | ||||||
|             case "hcaptcha": |  | ||||||
|                 content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, |  | ||||||
|                     "application/x-www-form-urlencoded"); |  | ||||||
|                 response = await client.PostAsync("https://hcaptcha.com/siteverify", content); |  | ||||||
|                 response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|                 json = await response.Content.ReadAsStringAsync(); |  | ||||||
|                 result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts); |  | ||||||
|  |  | ||||||
|                 return result?.Success == true; |  | ||||||
|             default: |  | ||||||
|                 throw new ArgumentException("The server misconfigured for the captcha."); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public string CreateToken(Session session) |  | ||||||
|     { |  | ||||||
|         // Load the private key for signing |  | ||||||
|         var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!); |  | ||||||
|         using var rsa = RSA.Create(); |  | ||||||
|         rsa.ImportFromPem(privateKeyPem); |  | ||||||
|  |  | ||||||
|         // Create and return a single token |  | ||||||
|         return CreateCompactToken(session.Id, rsa); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private string CreateCompactToken(Guid sessionId, RSA rsa) |  | ||||||
|     { |  | ||||||
|         // Create the payload: just the session ID |  | ||||||
|         var payloadBytes = sessionId.ToByteArray(); |  | ||||||
|  |  | ||||||
|         // Base64Url encode the payload |  | ||||||
|         var payloadBase64 = Base64UrlEncode(payloadBytes); |  | ||||||
|  |  | ||||||
|         // Sign the payload with RSA-SHA256 |  | ||||||
|         var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); |  | ||||||
|  |  | ||||||
|         // Base64Url encode the signature |  | ||||||
|         var signatureBase64 = Base64UrlEncode(signature); |  | ||||||
|  |  | ||||||
|         // Combine payload and signature with a dot |  | ||||||
|         return $"{payloadBase64}.{signatureBase64}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> ValidateSudoMode(Session session, string? pinCode) |  | ||||||
|     { |  | ||||||
|         // Check if the session is already in sudo mode (cached) |  | ||||||
|         var sudoModeKey = $"accounts:{session.Id}:sudo"; |  | ||||||
|         var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey); |  | ||||||
|          |  | ||||||
|         if (found) |  | ||||||
|         { |  | ||||||
|             // Session is already in sudo mode |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Check if the user has a pin code |  | ||||||
|         var hasPinCode = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == session.AccountId) |  | ||||||
|             .Where(f => f.EnabledAt != null) |  | ||||||
|             .Where(f => f.Type == AccountAuthFactorType.PinCode) |  | ||||||
|             .AnyAsync(); |  | ||||||
|              |  | ||||||
|         if (!hasPinCode) |  | ||||||
|         { |  | ||||||
|             // User doesn't have a pin code, no validation needed |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // If pin code is not provided, we can't validate |  | ||||||
|         if (string.IsNullOrEmpty(pinCode)) |  | ||||||
|         { |  | ||||||
|             return false; |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             // Validate the pin code |  | ||||||
|             var isValid = await ValidatePinCode(session.AccountId, pinCode); |  | ||||||
|              |  | ||||||
|             if (isValid) |  | ||||||
|             { |  | ||||||
|                 // Set session in sudo mode for 5 minutes |  | ||||||
|                 await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5)); |  | ||||||
|             } |  | ||||||
|              |  | ||||||
|             return isValid; |  | ||||||
|         } |  | ||||||
|         catch (InvalidOperationException) |  | ||||||
|         { |  | ||||||
|             // No pin code enabled for this account, so validation is successful |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> ValidatePinCode(Guid accountId, string pinCode) |  | ||||||
|     { |  | ||||||
|         var factor = await db.AccountAuthFactors |  | ||||||
|             .Where(f => f.AccountId == accountId) |  | ||||||
|             .Where(f => f.EnabledAt != null) |  | ||||||
|             .Where(f => f.Type == AccountAuthFactorType.PinCode) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (factor is null) throw new InvalidOperationException("No pin code enabled for this account."); |  | ||||||
|  |  | ||||||
|         return factor.VerifyPassword(pinCode); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public bool ValidateToken(string token, out Guid sessionId) |  | ||||||
|     { |  | ||||||
|         sessionId = Guid.Empty; |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             // Split the token |  | ||||||
|             var parts = token.Split('.'); |  | ||||||
|             if (parts.Length != 2) |  | ||||||
|                 return false; |  | ||||||
|  |  | ||||||
|             // Decode the payload |  | ||||||
|             var payloadBytes = Base64UrlDecode(parts[0]); |  | ||||||
|  |  | ||||||
|             // Extract session ID |  | ||||||
|             sessionId = new Guid(payloadBytes); |  | ||||||
|  |  | ||||||
|             // Load public key for verification |  | ||||||
|             var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); |  | ||||||
|             using var rsa = RSA.Create(); |  | ||||||
|             rsa.ImportFromPem(publicKeyPem); |  | ||||||
|  |  | ||||||
|             // Verify signature |  | ||||||
|             var signature = Base64UrlDecode(parts[1]); |  | ||||||
|             return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             return false; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Helper methods for Base64Url encoding/decoding |  | ||||||
|     private static string Base64UrlEncode(byte[] data) |  | ||||||
|     { |  | ||||||
|         return Convert.ToBase64String(data) |  | ||||||
|             .TrimEnd('=') |  | ||||||
|             .Replace('+', '-') |  | ||||||
|             .Replace('/', '_'); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static byte[] Base64UrlDecode(string base64Url) |  | ||||||
|     { |  | ||||||
|         string padded = base64Url |  | ||||||
|             .Replace('-', '+') |  | ||||||
|             .Replace('_', '/'); |  | ||||||
|  |  | ||||||
|         switch (padded.Length % 4) |  | ||||||
|         { |  | ||||||
|             case 2: padded += "=="; break; |  | ||||||
|             case 3: padded += "="; break; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Convert.FromBase64String(padded); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,6 +0,0 @@ | |||||||
| namespace DysonNetwork.Sphere.Auth; |  | ||||||
|  |  | ||||||
| public class CaptchaVerificationResponse |  | ||||||
| { |  | ||||||
|     public bool Success { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,94 +0,0 @@ | |||||||
| using System.Security.Cryptography; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth; |  | ||||||
|  |  | ||||||
| public class CompactTokenService(IConfiguration config) |  | ||||||
| { |  | ||||||
|     private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]  |  | ||||||
|         ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing"); |  | ||||||
|      |  | ||||||
|     public string CreateToken(Session session) |  | ||||||
|     { |  | ||||||
|         // Load the private key for signing |  | ||||||
|         var privateKeyPem = File.ReadAllText(_privateKeyPath); |  | ||||||
|         using var rsa = RSA.Create(); |  | ||||||
|         rsa.ImportFromPem(privateKeyPem); |  | ||||||
|          |  | ||||||
|         // Create and return a single token |  | ||||||
|         return CreateCompactToken(session.Id, rsa); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private string CreateCompactToken(Guid sessionId, RSA rsa) |  | ||||||
|     { |  | ||||||
|         // Create the payload: just the session ID |  | ||||||
|         var payloadBytes = sessionId.ToByteArray(); |  | ||||||
|          |  | ||||||
|         // Base64Url encode the payload |  | ||||||
|         var payloadBase64 = Base64UrlEncode(payloadBytes); |  | ||||||
|          |  | ||||||
|         // Sign the payload with RSA-SHA256 |  | ||||||
|         var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); |  | ||||||
|          |  | ||||||
|         // Base64Url encode the signature |  | ||||||
|         var signatureBase64 = Base64UrlEncode(signature); |  | ||||||
|          |  | ||||||
|         // Combine payload and signature with a dot |  | ||||||
|         return $"{payloadBase64}.{signatureBase64}"; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public bool ValidateToken(string token, out Guid sessionId) |  | ||||||
|     { |  | ||||||
|         sessionId = Guid.Empty; |  | ||||||
|          |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             // Split the token |  | ||||||
|             var parts = token.Split('.'); |  | ||||||
|             if (parts.Length != 2) |  | ||||||
|                 return false; |  | ||||||
|              |  | ||||||
|             // Decode the payload |  | ||||||
|             var payloadBytes = Base64UrlDecode(parts[0]); |  | ||||||
|              |  | ||||||
|             // Extract session ID |  | ||||||
|             sessionId = new Guid(payloadBytes); |  | ||||||
|              |  | ||||||
|             // Load public key for verification |  | ||||||
|             var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); |  | ||||||
|             using var rsa = RSA.Create(); |  | ||||||
|             rsa.ImportFromPem(publicKeyPem); |  | ||||||
|              |  | ||||||
|             // Verify signature |  | ||||||
|             var signature = Base64UrlDecode(parts[1]); |  | ||||||
|             return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             return false; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     // Helper methods for Base64Url encoding/decoding |  | ||||||
|     private static string Base64UrlEncode(byte[] data) |  | ||||||
|     { |  | ||||||
|         return Convert.ToBase64String(data) |  | ||||||
|             .TrimEnd('=') |  | ||||||
|             .Replace('+', '-') |  | ||||||
|             .Replace('/', '_'); |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     private static byte[] Base64UrlDecode(string base64Url) |  | ||||||
|     { |  | ||||||
|         string padded = base64Url |  | ||||||
|             .Replace('-', '+') |  | ||||||
|             .Replace('_', '/'); |  | ||||||
|              |  | ||||||
|         switch (padded.Length % 4) |  | ||||||
|         { |  | ||||||
|             case 2: padded += "=="; break; |  | ||||||
|             case 3: padded += "="; break; |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         return Convert.FromBase64String(padded); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,242 +0,0 @@ | |||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Responses; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.Extensions.Options; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers; |  | ||||||
|  |  | ||||||
| [Route("/api/auth/open")] |  | ||||||
| [ApiController] |  | ||||||
| public class OidcProviderController( |  | ||||||
|     AppDatabase db, |  | ||||||
|     OidcProviderService oidcService, |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IOptions<OidcProviderOptions> options, |  | ||||||
|     ILogger<OidcProviderController> logger |  | ||||||
| ) |  | ||||||
|     : ControllerBase |  | ||||||
| { |  | ||||||
|     [HttpPost("token")] |  | ||||||
|     [Consumes("application/x-www-form-urlencoded")] |  | ||||||
|     public async Task<IActionResult> Token([FromForm] TokenRequest request) |  | ||||||
|     { |  | ||||||
|         switch (request.GrantType) |  | ||||||
|         { |  | ||||||
|             // Validate client credentials |  | ||||||
|             case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret): |  | ||||||
|                 return BadRequest("Client credentials are required"); |  | ||||||
|             case "authorization_code" when request.Code == null: |  | ||||||
|                 return BadRequest("Authorization code is required"); |  | ||||||
|             case "authorization_code": |  | ||||||
|             { |  | ||||||
|                 var client = await oidcService.FindClientByIdAsync(request.ClientId.Value); |  | ||||||
|                 if (client == null || |  | ||||||
|                     !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret)) |  | ||||||
|                     return BadRequest(new ErrorResponse |  | ||||||
|                         { Error = "invalid_client", ErrorDescription = "Invalid client credentials" }); |  | ||||||
|  |  | ||||||
|                 // Generate tokens |  | ||||||
|                 var tokenResponse = await oidcService.GenerateTokenResponseAsync( |  | ||||||
|                     clientId: request.ClientId.Value, |  | ||||||
|                     authorizationCode: request.Code!, |  | ||||||
|                     redirectUri: request.RedirectUri, |  | ||||||
|                     codeVerifier: request.CodeVerifier |  | ||||||
|                 ); |  | ||||||
|  |  | ||||||
|                 return Ok(tokenResponse); |  | ||||||
|             } |  | ||||||
|             case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken): |  | ||||||
|                 return BadRequest(new ErrorResponse |  | ||||||
|                     { Error = "invalid_request", ErrorDescription = "Refresh token is required" }); |  | ||||||
|             case "refresh_token": |  | ||||||
|             { |  | ||||||
|                 try |  | ||||||
|                 { |  | ||||||
|                     // Decode the base64 refresh token to get the session ID |  | ||||||
|                     var sessionIdBytes = Convert.FromBase64String(request.RefreshToken); |  | ||||||
|                     var sessionId = new Guid(sessionIdBytes); |  | ||||||
|  |  | ||||||
|                     // Find the session and related data |  | ||||||
|                     var session = await oidcService.FindSessionByIdAsync(sessionId); |  | ||||||
|                     var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                     if (session?.App is null || session.ExpiredAt < now) |  | ||||||
|                     { |  | ||||||
|                         return BadRequest(new ErrorResponse |  | ||||||
|                         { |  | ||||||
|                             Error = "invalid_grant", |  | ||||||
|                             ErrorDescription = "Invalid or expired refresh token" |  | ||||||
|                         }); |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     // Get the client |  | ||||||
|                     var client = session.App; |  | ||||||
|                     if (client == null) |  | ||||||
|                     { |  | ||||||
|                         return BadRequest(new ErrorResponse |  | ||||||
|                         { |  | ||||||
|                             Error = "invalid_client", |  | ||||||
|                             ErrorDescription = "Client not found" |  | ||||||
|                         }); |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     // Generate new tokens |  | ||||||
|                     var tokenResponse = await oidcService.GenerateTokenResponseAsync( |  | ||||||
|                         clientId: session.AppId!.Value, |  | ||||||
|                         sessionId: session.Id |  | ||||||
|                     ); |  | ||||||
|  |  | ||||||
|                     return Ok(tokenResponse); |  | ||||||
|                 } |  | ||||||
|                 catch (FormatException) |  | ||||||
|                 { |  | ||||||
|                     return BadRequest(new ErrorResponse |  | ||||||
|                     { |  | ||||||
|                         Error = "invalid_grant", |  | ||||||
|                         ErrorDescription = "Invalid refresh token format" |  | ||||||
|                     }); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             default: |  | ||||||
|                 return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" }); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("userinfo")] |  | ||||||
|     [Authorize] |  | ||||||
|     public async Task<IActionResult> GetUserInfo() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || |  | ||||||
|             HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); |  | ||||||
|  |  | ||||||
|         // Get requested scopes from the token |  | ||||||
|         var scopes = currentSession.Challenge.Scopes; |  | ||||||
|  |  | ||||||
|         var userInfo = new Dictionary<string, object> |  | ||||||
|         { |  | ||||||
|             ["sub"] = currentUser.Id |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Include standard claims based on scopes |  | ||||||
|         if (scopes.Contains("profile") || scopes.Contains("name")) |  | ||||||
|         { |  | ||||||
|             userInfo["name"] = currentUser.Name; |  | ||||||
|             userInfo["preferred_username"] = currentUser.Nick; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var userEmail = await db.AccountContacts |  | ||||||
|             .Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (scopes.Contains("email") && userEmail is not null) |  | ||||||
|         { |  | ||||||
|             userInfo["email"] = userEmail.Content; |  | ||||||
|             userInfo["email_verified"] = userEmail.VerifiedAt is not null; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Ok(userInfo); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("/.well-known/openid-configuration")] |  | ||||||
|     public IActionResult GetConfiguration() |  | ||||||
|     { |  | ||||||
|         var baseUrl = configuration["BaseUrl"]; |  | ||||||
|         var issuer = options.Value.IssuerUri.TrimEnd('/'); |  | ||||||
|  |  | ||||||
|         return Ok(new |  | ||||||
|         { |  | ||||||
|             issuer = issuer, |  | ||||||
|             authorization_endpoint = $"{baseUrl}/auth/authorize", |  | ||||||
|             token_endpoint = $"{baseUrl}/auth/open/token", |  | ||||||
|             userinfo_endpoint = $"{baseUrl}/auth/open/userinfo", |  | ||||||
|             jwks_uri = $"{baseUrl}/.well-known/jwks", |  | ||||||
|             scopes_supported = new[] { "openid", "profile", "email" }, |  | ||||||
|             response_types_supported = new[] |  | ||||||
|                 { "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" }, |  | ||||||
|             grant_types_supported = new[] { "authorization_code", "refresh_token" }, |  | ||||||
|             token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" }, |  | ||||||
|             id_token_signing_alg_values_supported = new[] { "HS256" }, |  | ||||||
|             subject_types_supported = new[] { "public" }, |  | ||||||
|             claims_supported = new[] { "sub", "name", "email", "email_verified" }, |  | ||||||
|             code_challenge_methods_supported = new[] { "S256" }, |  | ||||||
|             response_modes_supported = new[] { "query", "fragment", "form_post" }, |  | ||||||
|             request_parameter_supported = true, |  | ||||||
|             request_uri_parameter_supported = true, |  | ||||||
|             require_request_uri_registration = false |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpGet("/.well-known/jwks")] |  | ||||||
|     public IActionResult GetJwks() |  | ||||||
|     { |  | ||||||
|         using var rsa = options.Value.GetRsaPublicKey(); |  | ||||||
|         if (rsa == null) |  | ||||||
|         { |  | ||||||
|             return BadRequest("Public key is not configured"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var parameters = rsa.ExportParameters(false); |  | ||||||
|         var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8]) |  | ||||||
|             .Replace("+", "-") |  | ||||||
|             .Replace("/", "_") |  | ||||||
|             .Replace("=", ""); |  | ||||||
|  |  | ||||||
|         return Ok(new |  | ||||||
|         { |  | ||||||
|             keys = new[] |  | ||||||
|             { |  | ||||||
|                 new |  | ||||||
|                 { |  | ||||||
|                     kty = "RSA", |  | ||||||
|                     use = "sig", |  | ||||||
|                     kid = keyId, |  | ||||||
|                     n = Base64UrlEncoder.Encode(parameters.Modulus!), |  | ||||||
|                     e = Base64UrlEncoder.Encode(parameters.Exponent!), |  | ||||||
|                     alg = "RS256" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class TokenRequest |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("grant_type")] |  | ||||||
|     [FromForm(Name = "grant_type")] |  | ||||||
|     public string? GrantType { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("code")] |  | ||||||
|     [FromForm(Name = "code")] |  | ||||||
|     public string? Code { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("redirect_uri")] |  | ||||||
|     [FromForm(Name = "redirect_uri")] |  | ||||||
|     public string? RedirectUri { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("client_id")] |  | ||||||
|     [FromForm(Name = "client_id")] |  | ||||||
|     public Guid? ClientId { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("client_secret")] |  | ||||||
|     [FromForm(Name = "client_secret")] |  | ||||||
|     public string? ClientSecret { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("refresh_token")] |  | ||||||
|     [FromForm(Name = "refresh_token")] |  | ||||||
|     public string? RefreshToken { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("scope")] |  | ||||||
|     [FromForm(Name = "scope")] |  | ||||||
|     public string? Scope { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("code_verifier")] |  | ||||||
|     [FromForm(Name = "code_verifier")] |  | ||||||
|     public string? CodeVerifier { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,17 +0,0 @@ | |||||||
| using System; |  | ||||||
| using System.Collections.Generic; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Models; |  | ||||||
|  |  | ||||||
| public class AuthorizationCodeInfo |  | ||||||
| { |  | ||||||
|     public Guid ClientId { get; set; } |  | ||||||
|     public Guid AccountId { get; set; } |  | ||||||
|     public string RedirectUri { get; set; } = string.Empty; |  | ||||||
|     public List<string> Scopes { get; set; } = new(); |  | ||||||
|     public string? CodeChallenge { get; set; } |  | ||||||
|     public string? CodeChallengeMethod { get; set; } |  | ||||||
|     public string? Nonce { get; set; } |  | ||||||
|     public Instant CreatedAt { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,36 +0,0 @@ | |||||||
| using System.Security.Cryptography; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Options; |  | ||||||
|  |  | ||||||
| public class OidcProviderOptions |  | ||||||
| { |  | ||||||
|     public string IssuerUri { get; set; } = "https://your-issuer-uri.com"; |  | ||||||
|     public string? PublicKeyPath { get; set; } |  | ||||||
|     public string? PrivateKeyPath { get; set; } |  | ||||||
|     public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1); |  | ||||||
|     public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30); |  | ||||||
|     public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5); |  | ||||||
|     public bool RequireHttpsMetadata { get; set; } = true; |  | ||||||
|  |  | ||||||
|     public RSA? GetRsaPrivateKey() |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath)) |  | ||||||
|             return null; |  | ||||||
|  |  | ||||||
|         var privateKey = File.ReadAllText(PrivateKeyPath); |  | ||||||
|         var rsa = RSA.Create(); |  | ||||||
|         rsa.ImportFromPem(privateKey.AsSpan()); |  | ||||||
|         return rsa; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public RSA? GetRsaPublicKey() |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath)) |  | ||||||
|             return null; |  | ||||||
|  |  | ||||||
|         var publicKey = File.ReadAllText(PublicKeyPath); |  | ||||||
|         var rsa = RSA.Create(); |  | ||||||
|         rsa.ImportFromPem(publicKey.AsSpan()); |  | ||||||
|         return rsa; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| using System.Text.Json.Serialization; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; |  | ||||||
|  |  | ||||||
| public class AuthorizationResponse |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("code")] |  | ||||||
|     public string Code { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("state")] |  | ||||||
|     public string? State { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("scope")] |  | ||||||
|     public string? Scope { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("session_state")] |  | ||||||
|     public string? SessionState { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("iss")] |  | ||||||
|     public string? Issuer { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,20 +0,0 @@ | |||||||
| using System.Text.Json.Serialization; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; |  | ||||||
|  |  | ||||||
| public class ErrorResponse |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("error")] |  | ||||||
|     public string Error { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("error_description")] |  | ||||||
|     public string? ErrorDescription { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("error_uri")] |  | ||||||
|     public string? ErrorUri { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("state")] |  | ||||||
|     public string? State { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,26 +0,0 @@ | |||||||
| using System.Text.Json.Serialization; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses; |  | ||||||
|  |  | ||||||
| public class TokenResponse |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("access_token")] |  | ||||||
|     public string AccessToken { get; set; } = null!; |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("expires_in")] |  | ||||||
|     public int ExpiresIn { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("token_type")] |  | ||||||
|     public string TokenType { get; set; } = "Bearer"; |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("refresh_token")] |  | ||||||
|     public string? RefreshToken { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("scope")] |  | ||||||
|     public string? Scope { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("id_token")] |  | ||||||
|     public string? IdToken { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,395 +0,0 @@ | |||||||
| using System.IdentityModel.Tokens.Jwt; |  | ||||||
| using System.Security.Claims; |  | ||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Models; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; |  | ||||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Responses; |  | ||||||
| using DysonNetwork.Sphere.Developer; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.Extensions.Options; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OidcProvider.Services; |  | ||||||
|  |  | ||||||
| public class OidcProviderService( |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache, |  | ||||||
|     IOptions<OidcProviderOptions> options, |  | ||||||
|     ILogger<OidcProviderService> logger |  | ||||||
| ) |  | ||||||
| { |  | ||||||
|     private readonly OidcProviderOptions _options = options.Value; |  | ||||||
|  |  | ||||||
|     public async Task<CustomApp?> FindClientByIdAsync(Guid clientId) |  | ||||||
|     { |  | ||||||
|         return await db.CustomApps |  | ||||||
|             .Include(c => c.Secrets) |  | ||||||
|             .FirstOrDefaultAsync(c => c.Id == clientId); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId) |  | ||||||
|     { |  | ||||||
|         return await db.CustomApps |  | ||||||
|             .Include(c => c.Secrets) |  | ||||||
|             .FirstOrDefaultAsync(c => c.Id == appId); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId) |  | ||||||
|     { |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|  |  | ||||||
|         return await db.AuthSessions |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Where(s => s.AccountId == accountId && |  | ||||||
|                         s.AppId == clientId && |  | ||||||
|                         (s.ExpiredAt == null || s.ExpiredAt > now) && |  | ||||||
|                         s.Challenge.Type == ChallengeType.OAuth) |  | ||||||
|             .OrderByDescending(s => s.CreatedAt) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret) |  | ||||||
|     { |  | ||||||
|         var client = await FindClientByIdAsync(clientId); |  | ||||||
|         if (client == null) return false; |  | ||||||
|  |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         var secret = client.Secrets |  | ||||||
|             .Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant())) |  | ||||||
|             .FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing |  | ||||||
|  |  | ||||||
|         return secret != null; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<TokenResponse> GenerateTokenResponseAsync( |  | ||||||
|         Guid clientId, |  | ||||||
|         string? authorizationCode = null, |  | ||||||
|         string? redirectUri = null, |  | ||||||
|         string? codeVerifier = null, |  | ||||||
|         Guid? sessionId = null |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var client = await FindClientByIdAsync(clientId); |  | ||||||
|         if (client == null) |  | ||||||
|             throw new InvalidOperationException("Client not found"); |  | ||||||
|  |  | ||||||
|         Session session; |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         var now = clock.GetCurrentInstant(); |  | ||||||
|  |  | ||||||
|         List<string>? scopes = null; |  | ||||||
|         if (authorizationCode != null) |  | ||||||
|         { |  | ||||||
|             // Authorization code flow |  | ||||||
|             var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); |  | ||||||
|             if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); |  | ||||||
|             var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); |  | ||||||
|             if (account is null) throw new InvalidOperationException("Account was not found"); |  | ||||||
|  |  | ||||||
|             session = await auth.CreateSessionForOidcAsync(account, now, client.Id); |  | ||||||
|             scopes = authCode.Scopes; |  | ||||||
|         } |  | ||||||
|         else if (sessionId.HasValue) |  | ||||||
|         { |  | ||||||
|             // Refresh token flow |  | ||||||
|             session = await FindSessionByIdAsync(sessionId.Value) ?? |  | ||||||
|                       throw new InvalidOperationException("Invalid session"); |  | ||||||
|  |  | ||||||
|             // Verify the session is still valid |  | ||||||
|             if (session.ExpiredAt < now) |  | ||||||
|                 throw new InvalidOperationException("Session has expired"); |  | ||||||
|         } |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Either authorization code or session ID must be provided"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; |  | ||||||
|         var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); |  | ||||||
|  |  | ||||||
|         // Generate an access token |  | ||||||
|         var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); |  | ||||||
|         var refreshToken = GenerateRefreshToken(session); |  | ||||||
|  |  | ||||||
|         return new TokenResponse |  | ||||||
|         { |  | ||||||
|             AccessToken = accessToken, |  | ||||||
|             ExpiresIn = expiresIn, |  | ||||||
|             TokenType = "Bearer", |  | ||||||
|             RefreshToken = refreshToken, |  | ||||||
|             Scope = scopes != null ? string.Join(" ", scopes) : null |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private string GenerateJwtToken( |  | ||||||
|         CustomApp client, |  | ||||||
|         Session session, |  | ||||||
|         Instant expiresAt, |  | ||||||
|         IEnumerable<string>? scopes = null |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var tokenHandler = new JwtSecurityTokenHandler(); |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         var now = clock.GetCurrentInstant(); |  | ||||||
|  |  | ||||||
|         var tokenDescriptor = new SecurityTokenDescriptor |  | ||||||
|         { |  | ||||||
|             Subject = new ClaimsIdentity([ |  | ||||||
|                 new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()), |  | ||||||
|                 new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), |  | ||||||
|                 new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), |  | ||||||
|                     ClaimValueTypes.Integer64), |  | ||||||
|                 new Claim("client_id", client.Id.ToString()) |  | ||||||
|             ]), |  | ||||||
|             Expires = expiresAt.ToDateTimeUtc(), |  | ||||||
|             Issuer = _options.IssuerUri, |  | ||||||
|             Audience = client.Id.ToString() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Try to use RSA signing if keys are available, fall back to HMAC |  | ||||||
|         var rsaPrivateKey = _options.GetRsaPrivateKey(); |  | ||||||
|         tokenDescriptor.SigningCredentials = new SigningCredentials( |  | ||||||
|             new RsaSecurityKey(rsaPrivateKey), |  | ||||||
|             SecurityAlgorithms.RsaSha256 |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         // Add scopes as claims if provided |  | ||||||
|         var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? []; |  | ||||||
|         if (effectiveScopes.Count != 0) |  | ||||||
|         { |  | ||||||
|             tokenDescriptor.Subject.AddClaims( |  | ||||||
|                 effectiveScopes.Select(scope => new Claim("scope", scope))); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var token = tokenHandler.CreateToken(tokenDescriptor); |  | ||||||
|         return tokenHandler.WriteToken(token); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public (bool isValid, JwtSecurityToken? token) ValidateToken(string token) |  | ||||||
|     { |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var tokenHandler = new JwtSecurityTokenHandler(); |  | ||||||
|             var validationParameters = new TokenValidationParameters |  | ||||||
|             { |  | ||||||
|                 ValidateIssuer = true, |  | ||||||
|                 ValidIssuer = _options.IssuerUri, |  | ||||||
|                 ValidateAudience = false, |  | ||||||
|                 ValidateLifetime = true, |  | ||||||
|                 ClockSkew = TimeSpan.Zero |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             // Try to use RSA validation if public key is available |  | ||||||
|             var rsaPublicKey = _options.GetRsaPublicKey(); |  | ||||||
|             validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey); |  | ||||||
|             validationParameters.ValidateIssuerSigningKey = true; |  | ||||||
|             validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }; |  | ||||||
|  |  | ||||||
|  |  | ||||||
|             tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); |  | ||||||
|             return (true, (JwtSecurityToken)validatedToken); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             logger.LogError(ex, "Token validation failed"); |  | ||||||
|             return (false, null); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<Session?> FindSessionByIdAsync(Guid sessionId) |  | ||||||
|     { |  | ||||||
|         return await db.AuthSessions |  | ||||||
|             .Include(s => s.Account) |  | ||||||
|             .Include(s => s.Challenge) |  | ||||||
|             .Include(s => s.App) |  | ||||||
|             .FirstOrDefaultAsync(s => s.Id == sessionId); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static string GenerateRefreshToken(Session session) |  | ||||||
|     { |  | ||||||
|         return Convert.ToBase64String(session.Id.ToByteArray()); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static bool VerifyHashedSecret(string secret, string hashedSecret) |  | ||||||
|     { |  | ||||||
|         // In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2 |  | ||||||
|         // For now, we'll do a simple comparison, but you should replace this with proper hashing |  | ||||||
|         return string.Equals(secret, hashedSecret, StringComparison.Ordinal); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync( |  | ||||||
|         Session session, |  | ||||||
|         Guid clientId, |  | ||||||
|         string redirectUri, |  | ||||||
|         IEnumerable<string> scopes, |  | ||||||
|         string? codeChallenge = null, |  | ||||||
|         string? codeChallengeMethod = null, |  | ||||||
|         string? nonce = null) |  | ||||||
|     { |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         var now = clock.GetCurrentInstant(); |  | ||||||
|         var code = Guid.NewGuid().ToString("N"); |  | ||||||
|  |  | ||||||
|         // Update the session's last activity time |  | ||||||
|         await db.AuthSessions.Where(s => s.Id == session.Id) |  | ||||||
|             .ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now)); |  | ||||||
|  |  | ||||||
|         // Create the authorization code info |  | ||||||
|         var authCodeInfo = new AuthorizationCodeInfo |  | ||||||
|         { |  | ||||||
|             ClientId = clientId, |  | ||||||
|             AccountId = session.AccountId, |  | ||||||
|             RedirectUri = redirectUri, |  | ||||||
|             Scopes = scopes.ToList(), |  | ||||||
|             CodeChallenge = codeChallenge, |  | ||||||
|             CodeChallengeMethod = codeChallengeMethod, |  | ||||||
|             Nonce = nonce, |  | ||||||
|             CreatedAt = now |  | ||||||
|         }; |  | ||||||
|          |  | ||||||
|         // Store the code with its metadata in the cache |  | ||||||
|         var cacheKey = $"auth:code:{code}"; |  | ||||||
|         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); |  | ||||||
|  |  | ||||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId); |  | ||||||
|         return code; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public async Task<string> GenerateAuthorizationCodeAsync( |  | ||||||
|         Guid clientId, |  | ||||||
|         Guid userId, |  | ||||||
|         string redirectUri, |  | ||||||
|         IEnumerable<string> scopes, |  | ||||||
|         string? codeChallenge = null, |  | ||||||
|         string? codeChallengeMethod = null, |  | ||||||
|         string? nonce = null |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         // Generate a random code |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         var code = GenerateRandomString(32); |  | ||||||
|         var now = clock.GetCurrentInstant(); |  | ||||||
|  |  | ||||||
|         // Create the authorization code info |  | ||||||
|         var authCodeInfo = new AuthorizationCodeInfo |  | ||||||
|         { |  | ||||||
|             ClientId = clientId, |  | ||||||
|             AccountId = userId, |  | ||||||
|             RedirectUri = redirectUri, |  | ||||||
|             Scopes = scopes.ToList(), |  | ||||||
|             CodeChallenge = codeChallenge, |  | ||||||
|             CodeChallengeMethod = codeChallengeMethod, |  | ||||||
|             Nonce = nonce, |  | ||||||
|             CreatedAt = now |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Store the code with its metadata in the cache |  | ||||||
|         var cacheKey = $"auth:code:{code}"; |  | ||||||
|         await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime); |  | ||||||
|  |  | ||||||
|         logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); |  | ||||||
|         return code; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync( |  | ||||||
|         string code, |  | ||||||
|         Guid clientId, |  | ||||||
|         string? redirectUri = null, |  | ||||||
|         string? codeVerifier = null |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var cacheKey = $"auth:code:{code}"; |  | ||||||
|         var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); |  | ||||||
|  |  | ||||||
|         if (!found || authCode == null) |  | ||||||
|         { |  | ||||||
|             logger.LogWarning("Authorization code not found: {Code}", code); |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Verify client ID matches |  | ||||||
|         if (authCode.ClientId != clientId) |  | ||||||
|         { |  | ||||||
|             logger.LogWarning( |  | ||||||
|                 "Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}", |  | ||||||
|                 code, authCode.ClientId, clientId); |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Verify redirect URI if provided |  | ||||||
|         if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri) |  | ||||||
|         { |  | ||||||
|             logger.LogWarning("Redirect URI mismatch for code {Code}", code); |  | ||||||
|             return null; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Verify PKCE code challenge if one was provided during authorization |  | ||||||
|         if (!string.IsNullOrEmpty(authCode.CodeChallenge)) |  | ||||||
|         { |  | ||||||
|             if (string.IsNullOrEmpty(codeVerifier)) |  | ||||||
|             { |  | ||||||
|                 logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code); |  | ||||||
|                 return null; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch |  | ||||||
|             { |  | ||||||
|                 "S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"), |  | ||||||
|                 "PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"), |  | ||||||
|                 _ => false // Unsupported code challenge method |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             if (!isValid) |  | ||||||
|             { |  | ||||||
|                 logger.LogWarning("PKCE code verifier validation failed for code {Code}", code); |  | ||||||
|                 return null; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Code is valid, remove it from the cache (codes are single-use) |  | ||||||
|         await cache.RemoveAsync(cacheKey); |  | ||||||
|  |  | ||||||
|         return authCode; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static string GenerateRandomString(int length) |  | ||||||
|     { |  | ||||||
|         const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"; |  | ||||||
|         var random = RandomNumberGenerator.Create(); |  | ||||||
|         var result = new char[length]; |  | ||||||
|  |  | ||||||
|         for (int i = 0; i < length; i++) |  | ||||||
|         { |  | ||||||
|             var randomNumber = new byte[4]; |  | ||||||
|             random.GetBytes(randomNumber); |  | ||||||
|             var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length); |  | ||||||
|             result[i] = chars[index]; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return new string(result); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(codeVerifier)) return false; |  | ||||||
|  |  | ||||||
|         if (method == "S256") |  | ||||||
|         { |  | ||||||
|             using var sha256 = SHA256.Create(); |  | ||||||
|             var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); |  | ||||||
|             var base64 = Base64UrlEncoder.Encode(hash); |  | ||||||
|             return string.Equals(base64, codeChallenge, StringComparison.Ordinal); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (method == "PLAIN") |  | ||||||
|         { |  | ||||||
|             return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,94 +0,0 @@ | |||||||
| using System.Net.Http.Json; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| public class AfdianOidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache, |  | ||||||
|     ILogger<AfdianOidcService> logger |  | ||||||
| ) |  | ||||||
|     : OidcService(configuration, httpClientFactory, db, auth, cache) |  | ||||||
| { |  | ||||||
|     public override string ProviderName => "Afdian"; |  | ||||||
|     protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint |  | ||||||
|     protected override string ConfigSectionName => "Afdian"; |  | ||||||
|  |  | ||||||
|     public override string GetAuthorizationUrl(string state, string nonce) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var queryParams = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|             { "response_type", "code" }, |  | ||||||
|             { "scope", "basic" }, |  | ||||||
|             { "state", state }, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); |  | ||||||
|         return $"https://afdian.com/oauth2/authorize?{queryString}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync() |  | ||||||
|     { |  | ||||||
|         return Task.FromResult(new OidcDiscoveryDocument |  | ||||||
|         { |  | ||||||
|             AuthorizationEndpoint = "https://afdian.com/oauth2/authorize", |  | ||||||
|             TokenEndpoint = "https://afdian.com/oauth2/access_token", |  | ||||||
|             UserinfoEndpoint = null, |  | ||||||
|             JwksUri = null |  | ||||||
|         })!; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) |  | ||||||
|     { |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var config = GetProviderConfig(); |  | ||||||
|             var content = new FormUrlEncodedContent(new Dictionary<string, string> |  | ||||||
|             { |  | ||||||
|                 { "client_id", config.ClientId }, |  | ||||||
|                 { "client_secret", config.ClientSecret }, |  | ||||||
|                 { "grant_type", "authorization_code" }, |  | ||||||
|                 { "code", callbackData.Code }, |  | ||||||
|                 { "redirect_uri", config.RedirectUri }, |  | ||||||
|             }); |  | ||||||
|  |  | ||||||
|             var client = HttpClientFactory.CreateClient(); |  | ||||||
|             var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token"); |  | ||||||
|             request.Content = content; |  | ||||||
|              |  | ||||||
|             var response = await client.SendAsync(request); |  | ||||||
|             response.EnsureSuccessStatusCode(); |  | ||||||
|              |  | ||||||
|             var json = await response.Content.ReadAsStringAsync(); |  | ||||||
|             logger.LogInformation("Trying get userinfo from afdian, response: {Response}", json); |  | ||||||
|             var afdianResponse = JsonDocument.Parse(json).RootElement; |  | ||||||
|  |  | ||||||
|             var user = afdianResponse.TryGetProperty("data", out var dataElement) ? dataElement : default; |  | ||||||
|             var userId = user.TryGetProperty("user_id", out var userIdElement) ? userIdElement.GetString() ?? "" : ""; |  | ||||||
|             var avatar = user.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; |  | ||||||
|  |  | ||||||
|             return new OidcUserInfo |  | ||||||
|             { |  | ||||||
|                 UserId = userId, |  | ||||||
|                 DisplayName = (user.TryGetProperty("name", out var nameElement) |  | ||||||
|                     ? nameElement.GetString() |  | ||||||
|                     : null) ?? "", |  | ||||||
|                 ProfilePictureUrl = avatar, |  | ||||||
|                 Provider = ProviderName |  | ||||||
|             }; |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             // Due to afidan's API isn't compliant with OAuth2, we want more logs from it to investigate. |  | ||||||
|             logger.LogError(ex, "Failed to get user info from Afdian"); |  | ||||||
|             throw; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
|  |  | ||||||
| using System.ComponentModel.DataAnnotations; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| public class AppleMobileConnectRequest |  | ||||||
| { |  | ||||||
|     [Required] |  | ||||||
|     public required string IdentityToken { get; set; } |  | ||||||
|     [Required] |  | ||||||
|     public required string AuthorizationCode { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AppleMobileSignInRequest : AppleMobileConnectRequest |  | ||||||
| { |  | ||||||
|     [Required] |  | ||||||
|     public required string DeviceId { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,279 +0,0 @@ | |||||||
| using System.IdentityModel.Tokens.Jwt; |  | ||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text; |  | ||||||
| using System.Text.Json; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Implementation of OpenID Connect service for Apple Sign In |  | ||||||
| /// </summary> |  | ||||||
| public class AppleOidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
|     : OidcService(configuration, httpClientFactory, db, auth, cache) |  | ||||||
| { |  | ||||||
|     private readonly IConfiguration _configuration = configuration; |  | ||||||
|     private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; |  | ||||||
|  |  | ||||||
|     public override string ProviderName => "apple"; |  | ||||||
|     protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration"; |  | ||||||
|     protected override string ConfigSectionName => "Apple"; |  | ||||||
|  |  | ||||||
|     public override string GetAuthorizationUrl(string state, string nonce) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|  |  | ||||||
|         var queryParams = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|             { "response_type", "code id_token" }, |  | ||||||
|             { "scope", "name email" }, |  | ||||||
|             { "response_mode", "form_post" }, |  | ||||||
|             { "state", state }, |  | ||||||
|             { "nonce", nonce } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); |  | ||||||
|         return $"https://appleid.apple.com/auth/authorize?{queryString}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) |  | ||||||
|     { |  | ||||||
|         // Verify and decode the id_token |  | ||||||
|         var userInfo = await ValidateTokenAsync(callbackData.IdToken); |  | ||||||
|  |  | ||||||
|         // If user data is provided in first login, parse it |  | ||||||
|         if (!string.IsNullOrEmpty(callbackData.RawData)) |  | ||||||
|         { |  | ||||||
|             var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData); |  | ||||||
|             if (userData?.Name != null) |  | ||||||
|             { |  | ||||||
|                 userInfo.FirstName = userData.Name.FirstName ?? ""; |  | ||||||
|                 userInfo.LastName = userData.Name.LastName ?? ""; |  | ||||||
|                 userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim(); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Exchange authorization code for access token (optional, if you need the access token) |  | ||||||
|         if (string.IsNullOrEmpty(callbackData.Code)) return userInfo; |  | ||||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); |  | ||||||
|         if (tokenResponse == null) return userInfo; |  | ||||||
|         userInfo.AccessToken = tokenResponse.AccessToken; |  | ||||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; |  | ||||||
|  |  | ||||||
|         return userInfo; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<OidcUserInfo> ValidateTokenAsync(string idToken) |  | ||||||
|     { |  | ||||||
|         // Get Apple's public keys |  | ||||||
|         var jwksJson = await GetAppleJwksAsync(); |  | ||||||
|         var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() }; |  | ||||||
|  |  | ||||||
|         // Parse the JWT header to get the key ID |  | ||||||
|         var handler = new JwtSecurityTokenHandler(); |  | ||||||
|         var jwtToken = handler.ReadJwtToken(idToken); |  | ||||||
|         var kid = jwtToken.Header.Kid; |  | ||||||
|  |  | ||||||
|         // Find the matching key |  | ||||||
|         var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid); |  | ||||||
|         if (key == null) |  | ||||||
|         { |  | ||||||
|             throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Create the validation parameters |  | ||||||
|         var validationParameters = new TokenValidationParameters |  | ||||||
|         { |  | ||||||
|             ValidateIssuer = true, |  | ||||||
|             ValidIssuer = "https://appleid.apple.com", |  | ||||||
|             ValidateAudience = true, |  | ||||||
|             ValidAudience = GetProviderConfig().ClientId, |  | ||||||
|             ValidateLifetime = true, |  | ||||||
|             IssuerSigningKey = key.ToSecurityKey() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         return ValidateAndExtractIdToken(idToken, validationParameters); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     protected override Dictionary<string, string> BuildTokenRequestParameters( |  | ||||||
|         string code, |  | ||||||
|         ProviderConfiguration config, |  | ||||||
|         string? codeVerifier |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         var parameters = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "client_secret", GenerateClientSecret() }, |  | ||||||
|             { "code", code }, |  | ||||||
|             { "grant_type", "authorization_code" }, |  | ||||||
|             { "redirect_uri", config.RedirectUri } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         return parameters; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<string> GetAppleJwksAsync() |  | ||||||
|     { |  | ||||||
|         var client = _httpClientFactory.CreateClient(); |  | ||||||
|         var response = await client.GetAsync("https://appleid.apple.com/auth/keys"); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         return await response.Content.ReadAsStringAsync(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Generates a client secret for Apple Sign In using JWT |  | ||||||
|     /// </summary> |  | ||||||
|     private string GenerateClientSecret() |  | ||||||
|     { |  | ||||||
|         var now = DateTime.UtcNow; |  | ||||||
|         var teamId = _configuration["Oidc:Apple:TeamId"]; |  | ||||||
|         var clientId = _configuration["Oidc:Apple:ClientId"]; |  | ||||||
|         var keyId = _configuration["Oidc:Apple:KeyId"]; |  | ||||||
|         var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"]; |  | ||||||
|  |  | ||||||
|         if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) || |  | ||||||
|             string.IsNullOrEmpty(privateKeyPath)) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, PrivateKeyPath)."); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Read the private key |  | ||||||
|         var privateKey = File.ReadAllText(privateKeyPath); |  | ||||||
|  |  | ||||||
|         // Create the JWT header |  | ||||||
|         var header = new Dictionary<string, object> |  | ||||||
|         { |  | ||||||
|             { "alg", "ES256" }, |  | ||||||
|             { "kid", keyId } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Create the JWT payload |  | ||||||
|         var payload = new Dictionary<string, object> |  | ||||||
|         { |  | ||||||
|             { "iss", teamId }, |  | ||||||
|             { "iat", ToUnixTimeSeconds(now) }, |  | ||||||
|             { "exp", ToUnixTimeSeconds(now.AddMinutes(5)) }, |  | ||||||
|             { "aud", "https://appleid.apple.com" }, |  | ||||||
|             { "sub", clientId } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Convert header and payload to Base64Url |  | ||||||
|         var headerJson = JsonSerializer.Serialize(header); |  | ||||||
|         var payloadJson = JsonSerializer.Serialize(payload); |  | ||||||
|         var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); |  | ||||||
|         var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); |  | ||||||
|  |  | ||||||
|         // Create the signature |  | ||||||
|         var dataToSign = $"{headerBase64}.{payloadBase64}"; |  | ||||||
|         var signature = SignWithECDsa(dataToSign, privateKey); |  | ||||||
|  |  | ||||||
|         // Combine all parts |  | ||||||
|         return $"{headerBase64}.{payloadBase64}.{signature}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private long ToUnixTimeSeconds(DateTime dateTime) |  | ||||||
|     { |  | ||||||
|         return new DateTimeOffset(dateTime).ToUnixTimeSeconds(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private string SignWithECDsa(string dataToSign, string privateKey) |  | ||||||
|     { |  | ||||||
|         using var ecdsa = ECDsa.Create(); |  | ||||||
|         ecdsa.ImportFromPem(privateKey); |  | ||||||
|  |  | ||||||
|         var bytes = Encoding.UTF8.GetBytes(dataToSign); |  | ||||||
|         var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256); |  | ||||||
|  |  | ||||||
|         return Base64UrlEncode(signature); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private string Base64UrlEncode(byte[] data) |  | ||||||
|     { |  | ||||||
|         return Convert.ToBase64String(data) |  | ||||||
|             .Replace('+', '-') |  | ||||||
|             .Replace('/', '_') |  | ||||||
|             .TrimEnd('='); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AppleUserData |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("name")] public AppleNameData? Name { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("email")] public string? Email { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AppleNameData |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("firstName")] public string? FirstName { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("lastName")] public string? LastName { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AppleJwks |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| public class AppleKey |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("kty")] public string? Kty { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("kid")] public string? Kid { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("use")] public string? Use { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("alg")] public string? Alg { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("n")] public string? N { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("e")] public string? E { get; set; } |  | ||||||
|  |  | ||||||
|     public SecurityKey ToSecurityKey() |  | ||||||
|     { |  | ||||||
|         if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E)) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Invalid key data"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var parameters = new RSAParameters |  | ||||||
|         { |  | ||||||
|             Modulus = Base64UrlDecode(N), |  | ||||||
|             Exponent = Base64UrlDecode(E) |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var rsa = RSA.Create(); |  | ||||||
|         rsa.ImportParameters(parameters); |  | ||||||
|  |  | ||||||
|         return new RsaSecurityKey(rsa); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private byte[] Base64UrlDecode(string input) |  | ||||||
|     { |  | ||||||
|         var output = input |  | ||||||
|             .Replace('-', '+') |  | ||||||
|             .Replace('_', '/'); |  | ||||||
|  |  | ||||||
|         switch (output.Length % 4) |  | ||||||
|         { |  | ||||||
|             case 0: break; |  | ||||||
|             case 2: output += "=="; break; |  | ||||||
|             case 3: output += "="; break; |  | ||||||
|             default: throw new InvalidOperationException("Invalid base64url string"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return Convert.FromBase64String(output); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,409 +0,0 @@ | |||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using Microsoft.AspNetCore.Authorization; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/accounts/me/connections")] |  | ||||||
| [Authorize] |  | ||||||
| public class ConnectionController( |  | ||||||
|     AppDatabase db, |  | ||||||
|     IEnumerable<OidcService> oidcServices, |  | ||||||
|     AccountService accounts, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) : ControllerBase |  | ||||||
| { |  | ||||||
|     private const string StateCachePrefix = "oidc-state:"; |  | ||||||
|     private const string ReturnUrlCachePrefix = "oidc-returning:"; |  | ||||||
|     private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15); |  | ||||||
|  |  | ||||||
|     [HttpGet] |  | ||||||
|     public async Task<ActionResult<List<AccountConnection>>> GetConnections() |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) |  | ||||||
|             return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var connections = await db.AccountConnections |  | ||||||
|             .Where(c => c.AccountId == currentUser.Id) |  | ||||||
|             .Select(c => new |  | ||||||
|             { |  | ||||||
|                 c.Id, |  | ||||||
|                 c.AccountId, |  | ||||||
|                 c.Provider, |  | ||||||
|                 c.ProvidedIdentifier, |  | ||||||
|                 c.Meta, |  | ||||||
|                 c.LastUsedAt, |  | ||||||
|                 c.CreatedAt, |  | ||||||
|                 c.UpdatedAt, |  | ||||||
|             }) |  | ||||||
|             .ToListAsync(); |  | ||||||
|         return Ok(connections); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpDelete("{id:guid}")] |  | ||||||
|     public async Task<ActionResult> RemoveConnection(Guid id) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) |  | ||||||
|             return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var connection = await db.AccountConnections |  | ||||||
|             .Where(c => c.Id == id && c.AccountId == currentUser.Id) |  | ||||||
|             .FirstOrDefaultAsync(); |  | ||||||
|         if (connection == null) |  | ||||||
|             return NotFound(); |  | ||||||
|  |  | ||||||
|         db.AccountConnections.Remove(connection); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [HttpPost("/auth/connect/apple/mobile")] |  | ||||||
|     public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) |  | ||||||
|             return Unauthorized(); |  | ||||||
|  |  | ||||||
|         if (GetOidcService("apple") is not AppleOidcService appleService) |  | ||||||
|             return StatusCode(503, "Apple OIDC service not available"); |  | ||||||
|  |  | ||||||
|         var callbackData = new OidcCallbackData |  | ||||||
|         { |  | ||||||
|             IdToken = request.IdentityToken, |  | ||||||
|             Code = request.AuthorizationCode, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         OidcUserInfo userInfo; |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             userInfo = await appleService.ProcessCallbackAsync(callbackData); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"Error processing Apple token: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var existingConnection = await db.AccountConnections |  | ||||||
|             .FirstOrDefaultAsync(c => |  | ||||||
|                 c.Provider == "apple" && |  | ||||||
|                 c.ProvidedIdentifier == userInfo.UserId); |  | ||||||
|  |  | ||||||
|         if (existingConnection != null) |  | ||||||
|         { |  | ||||||
|             return BadRequest( |  | ||||||
|                 $"This Apple account is already linked to {(existingConnection.AccountId == currentUser.Id ? "your account" : "another user")}."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         db.AccountConnections.Add(new AccountConnection |  | ||||||
|         { |  | ||||||
|             AccountId = currentUser.Id, |  | ||||||
|             Provider = "apple", |  | ||||||
|             ProvidedIdentifier = userInfo.UserId!, |  | ||||||
|             AccessToken = userInfo.AccessToken, |  | ||||||
|             RefreshToken = userInfo.RefreshToken, |  | ||||||
|             LastUsedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|             Meta = userInfo.ToMetadata(), |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return Ok(new { message = "Successfully connected Apple account." }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private OidcService? GetOidcService(string provider) |  | ||||||
|     { |  | ||||||
|         return oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase)); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public class ConnectProviderRequest |  | ||||||
|     { |  | ||||||
|         public string Provider { get; set; } = null!; |  | ||||||
|         public string? ReturnUrl { get; set; } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Initiates manual connection to an OAuth provider for the current user |  | ||||||
|     /// </summary> |  | ||||||
|     [HttpPost("connect")] |  | ||||||
|     public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request) |  | ||||||
|     { |  | ||||||
|         if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) |  | ||||||
|             return Unauthorized(); |  | ||||||
|  |  | ||||||
|         var oidcService = GetOidcService(request.Provider); |  | ||||||
|         if (oidcService == null) |  | ||||||
|             return BadRequest($"Provider '{request.Provider}' is not supported"); |  | ||||||
|  |  | ||||||
|         var existingConnection = await db.AccountConnections |  | ||||||
|             .AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName); |  | ||||||
|  |  | ||||||
|         if (existingConnection) |  | ||||||
|             return BadRequest($"You already have a {request.Provider} connection"); |  | ||||||
|  |  | ||||||
|         var state = Guid.NewGuid().ToString("N"); |  | ||||||
|         var nonce = Guid.NewGuid().ToString("N"); |  | ||||||
|         var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}"; |  | ||||||
|         var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections"; |  | ||||||
|  |  | ||||||
|         // Store state and return URL in cache |  | ||||||
|         await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration); |  | ||||||
|         await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration); |  | ||||||
|  |  | ||||||
|         var authUrl = oidcService.GetAuthorizationUrl(state, nonce); |  | ||||||
|  |  | ||||||
|         return Ok(new |  | ||||||
|         { |  | ||||||
|             authUrl, |  | ||||||
|             message = $"Redirect to this URL to connect your {request.Provider} account" |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     [AllowAnonymous] |  | ||||||
|     [Route("/api/auth/callback/{provider}")] |  | ||||||
|     [HttpGet, HttpPost] |  | ||||||
|     public async Task<IActionResult> HandleCallback([FromRoute] string provider) |  | ||||||
|     { |  | ||||||
|         var oidcService = GetOidcService(provider); |  | ||||||
|         if (oidcService == null) |  | ||||||
|             return BadRequest($"Provider '{provider}' is not supported."); |  | ||||||
|  |  | ||||||
|         var callbackData = await ExtractCallbackData(Request); |  | ||||||
|         if (callbackData.State == null) |  | ||||||
|             return BadRequest("State parameter is missing."); |  | ||||||
|  |  | ||||||
|         // Get the state from the cache |  | ||||||
|         var stateKey = $"{StateCachePrefix}{callbackData.State}"; |  | ||||||
|          |  | ||||||
|         // Try to get the state as OidcState first (new format) |  | ||||||
|         var oidcState = await cache.GetAsync<OidcState>(stateKey); |  | ||||||
|          |  | ||||||
|         // If not found, try to get as string (legacy format) |  | ||||||
|         if (oidcState == null) |  | ||||||
|         { |  | ||||||
|             var stateValue = await cache.GetAsync<string>(stateKey); |  | ||||||
|             if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null) |  | ||||||
|                 return BadRequest("Invalid or expired state parameter"); |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         // Remove the state from cache to prevent replay attacks |  | ||||||
|         await cache.RemoveAsync(stateKey); |  | ||||||
|  |  | ||||||
|         // Handle the flow based on state type |  | ||||||
|         if (oidcState.FlowType == OidcFlowType.Connect && oidcState.AccountId.HasValue) |  | ||||||
|         { |  | ||||||
|             // Connection flow |  | ||||||
|             if (oidcState.DeviceId != null) |  | ||||||
|             { |  | ||||||
|                 callbackData.State = oidcState.DeviceId; |  | ||||||
|             } |  | ||||||
|             return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value); |  | ||||||
|         } |  | ||||||
|         else if (oidcState.FlowType == OidcFlowType.Login) |  | ||||||
|         { |  | ||||||
|             // Login/Registration flow |  | ||||||
|             if (!string.IsNullOrEmpty(oidcState.DeviceId)) |  | ||||||
|             { |  | ||||||
|                 callbackData.State = oidcState.DeviceId; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Store return URL if provided |  | ||||||
|             if (!string.IsNullOrEmpty(oidcState.ReturnUrl) && oidcState.ReturnUrl != "/") |  | ||||||
|             { |  | ||||||
|                 var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}"; |  | ||||||
|                 await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             return await HandleLoginOrRegistration(provider, oidcService, callbackData); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return BadRequest("Unsupported flow type"); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<IActionResult> HandleManualConnection( |  | ||||||
|         string provider, |  | ||||||
|         OidcService oidcService, |  | ||||||
|         OidcCallbackData callbackData, |  | ||||||
|         Guid accountId |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         provider = provider.ToLower(); |  | ||||||
|  |  | ||||||
|         OidcUserInfo userInfo; |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             userInfo = await oidcService.ProcessCallbackAsync(callbackData); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"Error processing {provider} authentication: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (string.IsNullOrEmpty(userInfo.UserId)) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"{provider} did not return a valid user identifier."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Extract device ID from the callback state if available |  | ||||||
|         var deviceId = !string.IsNullOrEmpty(callbackData.State) ? callbackData.State : string.Empty; |  | ||||||
|  |  | ||||||
|         // Check if this provider account is already connected to any user |  | ||||||
|         var existingConnection = await db.AccountConnections |  | ||||||
|             .FirstOrDefaultAsync(c => |  | ||||||
|                 c.Provider == provider && |  | ||||||
|                 c.ProvidedIdentifier == userInfo.UserId); |  | ||||||
|  |  | ||||||
|         // If it's connected to a different user, return error |  | ||||||
|         if (existingConnection != null && existingConnection.AccountId != accountId) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"This {provider} account is already linked to another user."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Check if the current user already has this provider connected |  | ||||||
|         var userHasProvider = await db.AccountConnections |  | ||||||
|             .AnyAsync(c => |  | ||||||
|                 c.AccountId == accountId && |  | ||||||
|                 c.Provider == provider); |  | ||||||
|  |  | ||||||
|         if (userHasProvider) |  | ||||||
|         { |  | ||||||
|             // Update existing connection with new tokens |  | ||||||
|             var connection = await db.AccountConnections |  | ||||||
|                 .FirstOrDefaultAsync(c => |  | ||||||
|                     c.AccountId == accountId && |  | ||||||
|                     c.Provider == provider); |  | ||||||
|  |  | ||||||
|             if (connection != null) |  | ||||||
|             { |  | ||||||
|                 connection.AccessToken = userInfo.AccessToken; |  | ||||||
|                 connection.RefreshToken = userInfo.RefreshToken; |  | ||||||
|                 connection.LastUsedAt = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|                 connection.Meta = userInfo.ToMetadata(); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             // Create new connection |  | ||||||
|             db.AccountConnections.Add(new AccountConnection |  | ||||||
|             { |  | ||||||
|                 AccountId = accountId, |  | ||||||
|                 Provider = provider, |  | ||||||
|                 ProvidedIdentifier = userInfo.UserId!, |  | ||||||
|                 AccessToken = userInfo.AccessToken, |  | ||||||
|                 RefreshToken = userInfo.RefreshToken, |  | ||||||
|                 LastUsedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 Meta = userInfo.ToMetadata(), |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|         } |  | ||||||
|         catch (DbUpdateException) |  | ||||||
|         { |  | ||||||
|             return StatusCode(500, $"Failed to save {provider} connection. Please try again."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Clean up and redirect |  | ||||||
|         var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}"; |  | ||||||
|         var returnUrl = await cache.GetAsync<string>(returnUrlKey); |  | ||||||
|         await cache.RemoveAsync(returnUrlKey); |  | ||||||
|  |  | ||||||
|         return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<IActionResult> HandleLoginOrRegistration( |  | ||||||
|         string provider, |  | ||||||
|         OidcService oidcService, |  | ||||||
|         OidcCallbackData callbackData |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         OidcUserInfo userInfo; |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             userInfo = await oidcService.ProcessCallbackAsync(callbackData); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"Error processing callback: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId)) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"Email or user ID is missing from {provider}'s response"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var connection = await db.AccountConnections |  | ||||||
|             .Include(c => c.Account) |  | ||||||
|             .FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId); |  | ||||||
|  |  | ||||||
|         var clock = SystemClock.Instance; |  | ||||||
|         if (connection != null) |  | ||||||
|         { |  | ||||||
|             // Login existing user |  | ||||||
|             var deviceId = !string.IsNullOrEmpty(callbackData.State) ?  |  | ||||||
|                 callbackData.State.Split('|').FirstOrDefault() :  |  | ||||||
|                 string.Empty; |  | ||||||
|                  |  | ||||||
|             var challenge = await oidcService.CreateChallengeForUserAsync( |  | ||||||
|                 userInfo,  |  | ||||||
|                 connection.Account,  |  | ||||||
|                 HttpContext,  |  | ||||||
|                 deviceId ?? string.Empty); |  | ||||||
|             return Redirect($"/auth/callback?challenge={challenge.Id}"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Register new user |  | ||||||
|         var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo); |  | ||||||
|  |  | ||||||
|         // Create connection for new or existing user |  | ||||||
|         var newConnection = new AccountConnection |  | ||||||
|         { |  | ||||||
|             Account = account, |  | ||||||
|             Provider = provider, |  | ||||||
|             ProvidedIdentifier = userInfo.UserId!, |  | ||||||
|             AccessToken = userInfo.AccessToken, |  | ||||||
|             RefreshToken = userInfo.RefreshToken, |  | ||||||
|             LastUsedAt = clock.GetCurrentInstant(), |  | ||||||
|             Meta = userInfo.ToMetadata() |  | ||||||
|         }; |  | ||||||
|         db.AccountConnections.Add(newConnection); |  | ||||||
|  |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant()); |  | ||||||
|         var loginToken = auth.CreateToken(loginSession); |  | ||||||
|         return Redirect($"/auth/token?token={loginToken}"); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request) |  | ||||||
|     { |  | ||||||
|         var data = new OidcCallbackData(); |  | ||||||
|         switch (request.Method) |  | ||||||
|         { |  | ||||||
|             case "GET": |  | ||||||
|                 data.Code = Uri.UnescapeDataString(request.Query["code"].FirstOrDefault() ?? ""); |  | ||||||
|                 data.IdToken = Uri.UnescapeDataString(request.Query["id_token"].FirstOrDefault() ?? ""); |  | ||||||
|                 data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? ""); |  | ||||||
|                 break; |  | ||||||
|             case "POST" when request.HasFormContentType: |  | ||||||
|             { |  | ||||||
|                 var form = await request.ReadFormAsync(); |  | ||||||
|                 data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? ""); |  | ||||||
|                 data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");  |  | ||||||
|                 data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? ""); |  | ||||||
|                 if (form.ContainsKey("user")) |  | ||||||
|                     data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? ""); |  | ||||||
|  |  | ||||||
|                 break; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return data; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,115 +0,0 @@ | |||||||
| using System.Net.Http.Json; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| public class DiscordOidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
|     : OidcService(configuration, httpClientFactory, db, auth, cache) |  | ||||||
| { |  | ||||||
|     public override string ProviderName => "Discord"; |  | ||||||
|     protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint |  | ||||||
|     protected override string ConfigSectionName => "Discord"; |  | ||||||
|  |  | ||||||
|     public override string GetAuthorizationUrl(string state, string nonce) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var queryParams = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|             { "response_type", "code" }, |  | ||||||
|             { "scope", "identify email" }, |  | ||||||
|             { "state", state }, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); |  | ||||||
|         return $"https://discord.com/oauth2/authorize?{queryString}"; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync() |  | ||||||
|     { |  | ||||||
|         return Task.FromResult(new OidcDiscoveryDocument |  | ||||||
|         { |  | ||||||
|             AuthorizationEndpoint = "https://discord.com/oauth2/authorize", |  | ||||||
|             TokenEndpoint = "https://discord.com/oauth2/token", |  | ||||||
|             UserinfoEndpoint = "https://discord.com/users/@me", |  | ||||||
|             JwksUri = null |  | ||||||
|         })!; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) |  | ||||||
|     { |  | ||||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); |  | ||||||
|         if (tokenResponse?.AccessToken == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Failed to obtain access token from Discord"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); |  | ||||||
|  |  | ||||||
|         userInfo.AccessToken = tokenResponse.AccessToken; |  | ||||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; |  | ||||||
|  |  | ||||||
|         return userInfo; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, |  | ||||||
|         string? codeVerifier = null) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|  |  | ||||||
|         var content = new FormUrlEncodedContent(new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "client_secret", config.ClientSecret }, |  | ||||||
|             { "grant_type", "authorization_code" }, |  | ||||||
|             { "code", code }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         var response = await client.PostAsync("https://discord.com/oauth2/token", content); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) |  | ||||||
|     { |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me"); |  | ||||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); |  | ||||||
|  |  | ||||||
|         var response = await client.SendAsync(request); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         var json = await response.Content.ReadAsStringAsync(); |  | ||||||
|         var discordUser = JsonDocument.Parse(json).RootElement; |  | ||||||
|  |  | ||||||
|         var userId = discordUser.GetProperty("id").GetString() ?? ""; |  | ||||||
|         var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; |  | ||||||
|  |  | ||||||
|         return new OidcUserInfo |  | ||||||
|         { |  | ||||||
|             UserId = userId, |  | ||||||
|             Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "", |  | ||||||
|             EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) && |  | ||||||
|                             verifiedElement.GetBoolean(), |  | ||||||
|             DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement) |  | ||||||
|                 ? globalNameElement.GetString() |  | ||||||
|                 : null) ?? "", |  | ||||||
|             PreferredUsername = discordUser.GetProperty("username").GetString() ?? "", |  | ||||||
|             ProfilePictureUrl = !string.IsNullOrEmpty(avatar) |  | ||||||
|                 ? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png" |  | ||||||
|                 : "", |  | ||||||
|             Provider = ProviderName |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,127 +0,0 @@ | |||||||
| using System.Net.Http.Json; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| public class GitHubOidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
|     : OidcService(configuration, httpClientFactory, db, auth, cache) |  | ||||||
| { |  | ||||||
|     public override string ProviderName => "GitHub"; |  | ||||||
|     protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint |  | ||||||
|     protected override string ConfigSectionName => "GitHub"; |  | ||||||
|  |  | ||||||
|     public override string GetAuthorizationUrl(string state, string nonce) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var queryParams = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|             { "scope", "user:email" }, |  | ||||||
|             { "state", state }, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); |  | ||||||
|         return $"https://github.com/login/oauth/authorize?{queryString}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) |  | ||||||
|     { |  | ||||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); |  | ||||||
|         if (tokenResponse?.AccessToken == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Failed to obtain access token from GitHub"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); |  | ||||||
|  |  | ||||||
|         userInfo.AccessToken = tokenResponse.AccessToken; |  | ||||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; |  | ||||||
|  |  | ||||||
|         return userInfo; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, |  | ||||||
|         string? codeVerifier = null) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|  |  | ||||||
|         var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") |  | ||||||
|         { |  | ||||||
|             Content = new FormUrlEncodedContent(new Dictionary<string, string> |  | ||||||
|             { |  | ||||||
|                 { "client_id", config.ClientId }, |  | ||||||
|                 { "client_secret", config.ClientSecret }, |  | ||||||
|                 { "code", code }, |  | ||||||
|                 { "redirect_uri", config.RedirectUri }, |  | ||||||
|             }) |  | ||||||
|         }; |  | ||||||
|         tokenRequest.Headers.Add("Accept", "application/json"); |  | ||||||
|  |  | ||||||
|         var response = await client.SendAsync(tokenRequest); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) |  | ||||||
|     { |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); |  | ||||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); |  | ||||||
|         request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); |  | ||||||
|  |  | ||||||
|         var response = await client.SendAsync(request); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         var json = await response.Content.ReadAsStringAsync(); |  | ||||||
|         var githubUser = JsonDocument.Parse(json).RootElement; |  | ||||||
|  |  | ||||||
|         var email = githubUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null; |  | ||||||
|         if (string.IsNullOrEmpty(email)) |  | ||||||
|         { |  | ||||||
|             email = await GetPrimaryEmailAsync(accessToken); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return new OidcUserInfo |  | ||||||
|         { |  | ||||||
|             UserId = githubUser.GetProperty("id").GetInt64().ToString(), |  | ||||||
|             Email = email, |  | ||||||
|             DisplayName = githubUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "", |  | ||||||
|             PreferredUsername = githubUser.GetProperty("login").GetString() ?? "", |  | ||||||
|             ProfilePictureUrl = githubUser.TryGetProperty("avatar_url", out var avatarElement) |  | ||||||
|                 ? avatarElement.GetString() ?? "" |  | ||||||
|                 : "", |  | ||||||
|             Provider = ProviderName |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<string?> GetPrimaryEmailAsync(string accessToken) |  | ||||||
|     { |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|         var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails"); |  | ||||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); |  | ||||||
|         request.Headers.Add("User-Agent", "DysonNetwork.Sphere"); |  | ||||||
|  |  | ||||||
|         var response = await client.SendAsync(request); |  | ||||||
|         if (!response.IsSuccessStatusCode) return null; |  | ||||||
|  |  | ||||||
|         var emails = await response.Content.ReadFromJsonAsync<List<GitHubEmail>>(); |  | ||||||
|         return emails?.FirstOrDefault(e => e.Primary)?.Email; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private class GitHubEmail |  | ||||||
|     { |  | ||||||
|         public string Email { get; set; } = ""; |  | ||||||
|         public bool Primary { get; set; } |  | ||||||
|         public bool Verified { get; set; } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,136 +0,0 @@ | |||||||
| using System.IdentityModel.Tokens.Jwt; |  | ||||||
| using System.Net.Http.Json; |  | ||||||
| using System.Security.Cryptography; |  | ||||||
| using System.Text; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| public class GoogleOidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
|     : OidcService(configuration, httpClientFactory, db, auth, cache) |  | ||||||
| { |  | ||||||
|     private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; |  | ||||||
|  |  | ||||||
|     public override string ProviderName => "google"; |  | ||||||
|     protected override string DiscoveryEndpoint => "https://accounts.google.com/.well-known/openid-configuration"; |  | ||||||
|     protected override string ConfigSectionName => "Google"; |  | ||||||
|  |  | ||||||
|     public override string GetAuthorizationUrl(string state, string nonce) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult(); |  | ||||||
|  |  | ||||||
|         if (discoveryDocument?.AuthorizationEndpoint == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Authorization endpoint not found in discovery document"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var queryParams = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|             { "response_type", "code" }, |  | ||||||
|             { "scope", "openid email profile" }, |  | ||||||
|             { "state", state }, // No '|codeVerifier' appended anymore |  | ||||||
|             { "nonce", nonce } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); |  | ||||||
|         return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) |  | ||||||
|     { |  | ||||||
|         // No need to split or parse code verifier from state |  | ||||||
|         var state = callbackData.State ?? ""; |  | ||||||
|         callbackData.State = state; // Keep the original state if needed |  | ||||||
|  |  | ||||||
|         // Exchange the code for tokens |  | ||||||
|         // Pass null or omit the parameter for codeVerifier as PKCE is removed |  | ||||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, null); |  | ||||||
|         if (tokenResponse?.IdToken == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Failed to obtain ID token from Google"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Validate the ID token |  | ||||||
|         var userInfo = await ValidateTokenAsync(tokenResponse.IdToken); |  | ||||||
|  |  | ||||||
|         // Set tokens on the user info |  | ||||||
|         userInfo.AccessToken = tokenResponse.AccessToken; |  | ||||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; |  | ||||||
|  |  | ||||||
|         // Try to fetch additional profile data if userinfo endpoint is available |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var discoveryDocument = await GetDiscoveryDocumentAsync(); |  | ||||||
|             if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken)) |  | ||||||
|             { |  | ||||||
|                 var client = _httpClientFactory.CreateClient(); |  | ||||||
|                 client.DefaultRequestHeaders.Authorization = |  | ||||||
|                     new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); |  | ||||||
|  |  | ||||||
|                 var userInfoResponse = |  | ||||||
|                     await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint); |  | ||||||
|  |  | ||||||
|                 if (userInfoResponse != null) |  | ||||||
|                 { |  | ||||||
|                     if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null) |  | ||||||
|                     { |  | ||||||
|                         userInfo.ProfilePictureUrl = picture.ToString(); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             // Ignore errors when fetching additional profile data |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return userInfo; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<OidcUserInfo> ValidateTokenAsync(string idToken) |  | ||||||
|     { |  | ||||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); |  | ||||||
|         if (discoveryDocument?.JwksUri == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("JWKS URI not found in discovery document"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var client = _httpClientFactory.CreateClient(); |  | ||||||
|         var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri); |  | ||||||
|         if (jwksResponse == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Failed to retrieve JWKS from Google"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var handler = new JwtSecurityTokenHandler(); |  | ||||||
|         var jwtToken = handler.ReadJwtToken(idToken); |  | ||||||
|         var kid = jwtToken.Header.Kid; |  | ||||||
|         var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid); |  | ||||||
|         if (signingKey == null) |  | ||||||
|         { |  | ||||||
|             throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var validationParameters = new TokenValidationParameters |  | ||||||
|         { |  | ||||||
|             ValidateIssuer = true, |  | ||||||
|             ValidIssuer = "https://accounts.google.com", |  | ||||||
|             ValidateAudience = true, |  | ||||||
|             ValidAudience = GetProviderConfig().ClientId, |  | ||||||
|             ValidateLifetime = true, |  | ||||||
|             IssuerSigningKey = signingKey |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         return ValidateAndExtractIdToken(idToken, validationParameters); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,124 +0,0 @@ | |||||||
| using System.Net.Http.Json; |  | ||||||
| using System.Text.Json; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| public class MicrosoftOidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
|     : OidcService(configuration, httpClientFactory, db, auth, cache) |  | ||||||
| { |  | ||||||
|     public override string ProviderName => "Microsoft"; |  | ||||||
|  |  | ||||||
|     protected override string DiscoveryEndpoint => Configuration[$"Oidc:{ConfigSectionName}:DiscoveryEndpoint"] ?? |  | ||||||
|                                                    throw new InvalidOperationException( |  | ||||||
|                                                        "Microsoft OIDC discovery endpoint is not configured."); |  | ||||||
|  |  | ||||||
|     protected override string ConfigSectionName => "Microsoft"; |  | ||||||
|  |  | ||||||
|     public override string GetAuthorizationUrl(string state, string nonce) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult(); |  | ||||||
|         if (discoveryDocument?.AuthorizationEndpoint == null) |  | ||||||
|             throw new InvalidOperationException("Authorization endpoint not found in discovery document."); |  | ||||||
|  |  | ||||||
|         var queryParams = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "response_type", "code" }, |  | ||||||
|             { "redirect_uri", config.RedirectUri }, |  | ||||||
|             { "response_mode", "query" }, |  | ||||||
|             { "scope", "openid profile email" }, |  | ||||||
|             { "state", state }, |  | ||||||
|             { "nonce", nonce }, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); |  | ||||||
|         return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}"; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData) |  | ||||||
|     { |  | ||||||
|         var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); |  | ||||||
|         if (tokenResponse?.AccessToken == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Failed to obtain access token from Microsoft"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); |  | ||||||
|  |  | ||||||
|         userInfo.AccessToken = tokenResponse.AccessToken; |  | ||||||
|         userInfo.RefreshToken = tokenResponse.RefreshToken; |  | ||||||
|  |  | ||||||
|         return userInfo; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, |  | ||||||
|         string? codeVerifier = null) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); |  | ||||||
|         if (discoveryDocument?.TokenEndpoint == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Token endpoint not found in discovery document."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|  |  | ||||||
|         var tokenRequest = new HttpRequestMessage(HttpMethod.Post, discoveryDocument.TokenEndpoint) |  | ||||||
|         { |  | ||||||
|             Content = new FormUrlEncodedContent(new Dictionary<string, string> |  | ||||||
|             { |  | ||||||
|                 { "client_id", config.ClientId }, |  | ||||||
|                 { "scope", "openid profile email" }, |  | ||||||
|                 { "code", code }, |  | ||||||
|                 { "redirect_uri", config.RedirectUri }, |  | ||||||
|                 { "grant_type", "authorization_code" }, |  | ||||||
|                 { "client_secret", config.ClientSecret }, |  | ||||||
|             }) |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         var response = await client.SendAsync(tokenRequest); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken) |  | ||||||
|     { |  | ||||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); |  | ||||||
|         if (discoveryDocument?.UserinfoEndpoint == null) |  | ||||||
|             throw new InvalidOperationException("Userinfo endpoint not found in discovery document."); |  | ||||||
|  |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|         var request = new HttpRequestMessage(HttpMethod.Get, discoveryDocument.UserinfoEndpoint); |  | ||||||
|         request.Headers.Add("Authorization", $"Bearer {accessToken}"); |  | ||||||
|  |  | ||||||
|         var response = await client.SendAsync(request); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         var json = await response.Content.ReadAsStringAsync(); |  | ||||||
|         var microsoftUser = JsonDocument.Parse(json).RootElement; |  | ||||||
|  |  | ||||||
|         return new OidcUserInfo |  | ||||||
|         { |  | ||||||
|             UserId = microsoftUser.GetProperty("sub").GetString() ?? "", |  | ||||||
|             Email = microsoftUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null, |  | ||||||
|             DisplayName = |  | ||||||
|                 microsoftUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "", |  | ||||||
|             PreferredUsername = microsoftUser.TryGetProperty("preferred_username", out var preferredUsernameElement) |  | ||||||
|                 ? preferredUsernameElement.GetString() ?? "" |  | ||||||
|                 : "", |  | ||||||
|             ProfilePictureUrl = microsoftUser.TryGetProperty("picture", out var pictureElement) |  | ||||||
|                 ? pictureElement.GetString() ?? "" |  | ||||||
|                 : "", |  | ||||||
|             Provider = ProviderName |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,194 +0,0 @@ | |||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.AspNetCore.Mvc; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| [ApiController] |  | ||||||
| [Route("/api/auth/login")] |  | ||||||
| public class OidcController( |  | ||||||
|     IServiceProvider serviceProvider, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AccountService accounts, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
|     : ControllerBase |  | ||||||
| { |  | ||||||
|     private const string StateCachePrefix = "oidc-state:"; |  | ||||||
|     private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15); |  | ||||||
|  |  | ||||||
|     [HttpGet("{provider}")] |  | ||||||
|     public async Task<ActionResult> OidcLogin( |  | ||||||
|         [FromRoute] string provider, |  | ||||||
|         [FromQuery] string? returnUrl = "/", |  | ||||||
|         [FromHeader(Name = "X-Device-Id")] string? deviceId = null |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             var oidcService = GetOidcService(provider); |  | ||||||
|  |  | ||||||
|             // If the user is already authenticated, treat as an account connection request |  | ||||||
|             if (HttpContext.Items["CurrentUser"] is Account.Account currentUser) |  | ||||||
|             { |  | ||||||
|                 var state = Guid.NewGuid().ToString(); |  | ||||||
|                 var nonce = Guid.NewGuid().ToString(); |  | ||||||
|  |  | ||||||
|                 // Create and store connection state |  | ||||||
|                 var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId); |  | ||||||
|                 await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); |  | ||||||
|  |  | ||||||
|                 // The state parameter sent to the provider is the GUID key for the cache. |  | ||||||
|                 var authUrl = oidcService.GetAuthorizationUrl(state, nonce); |  | ||||||
|                 return Redirect(authUrl); |  | ||||||
|             } |  | ||||||
|             else // Otherwise, proceed with the login / registration flow |  | ||||||
|             { |  | ||||||
|                 var nonce = Guid.NewGuid().ToString(); |  | ||||||
|                 var state = Guid.NewGuid().ToString(); |  | ||||||
|  |  | ||||||
|                 // Create login state with return URL and device ID |  | ||||||
|                 var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId); |  | ||||||
|                 await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); |  | ||||||
|                 var authUrl = oidcService.GetAuthorizationUrl(state, nonce); |  | ||||||
|                 return Redirect(authUrl); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Mobile Apple Sign In endpoint |  | ||||||
|     /// Handles Apple authentication directly from mobile apps |  | ||||||
|     /// </summary> |  | ||||||
|     [HttpPost("apple/mobile")] |  | ||||||
|     public async Task<ActionResult<Challenge>> AppleMobileLogin( |  | ||||||
|         [FromBody] AppleMobileSignInRequest request) |  | ||||||
|     { |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             // Get Apple OIDC service |  | ||||||
|             if (GetOidcService("apple") is not AppleOidcService appleService) |  | ||||||
|                 return StatusCode(503, "Apple OIDC service not available"); |  | ||||||
|  |  | ||||||
|             // Prepare callback data for processing |  | ||||||
|             var callbackData = new OidcCallbackData |  | ||||||
|             { |  | ||||||
|                 IdToken = request.IdentityToken, |  | ||||||
|                 Code = request.AuthorizationCode, |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             // Process the authentication |  | ||||||
|             var userInfo = await appleService.ProcessCallbackAsync(callbackData); |  | ||||||
|  |  | ||||||
|             // Find or create user account using existing logic |  | ||||||
|             var account = await FindOrCreateAccount(userInfo, "apple"); |  | ||||||
|  |  | ||||||
|             // Create session using the OIDC service |  | ||||||
|             var challenge = await appleService.CreateChallengeForUserAsync( |  | ||||||
|                 userInfo, |  | ||||||
|                 account, |  | ||||||
|                 HttpContext, |  | ||||||
|                 request.DeviceId |  | ||||||
|             ); |  | ||||||
|  |  | ||||||
|             return Ok(challenge); |  | ||||||
|         } |  | ||||||
|         catch (SecurityTokenValidationException ex) |  | ||||||
|         { |  | ||||||
|             return Unauthorized($"Invalid identity token: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|         catch (Exception ex) |  | ||||||
|         { |  | ||||||
|             // Log the error |  | ||||||
|             return StatusCode(500, $"Authentication failed: {ex.Message}"); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private OidcService GetOidcService(string provider) |  | ||||||
|     { |  | ||||||
|         return provider.ToLower() switch |  | ||||||
|         { |  | ||||||
|             "apple" => serviceProvider.GetRequiredService<AppleOidcService>(), |  | ||||||
|             "google" => serviceProvider.GetRequiredService<GoogleOidcService>(), |  | ||||||
|             "microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(), |  | ||||||
|             "discord" => serviceProvider.GetRequiredService<DiscordOidcService>(), |  | ||||||
|             "github" => serviceProvider.GetRequiredService<GitHubOidcService>(), |  | ||||||
|             "afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(), |  | ||||||
|             _ => throw new ArgumentException($"Unsupported provider: {provider}") |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider) |  | ||||||
|     { |  | ||||||
|         if (string.IsNullOrEmpty(userInfo.Email)) |  | ||||||
|             throw new ArgumentException("Email is required for account creation"); |  | ||||||
|  |  | ||||||
|         // Check if an account exists by email |  | ||||||
|         var existingAccount = await accounts.LookupAccount(userInfo.Email); |  | ||||||
|         if (existingAccount != null) |  | ||||||
|         { |  | ||||||
|             // Check if this provider connection already exists |  | ||||||
|             var existingConnection = await db.AccountConnections |  | ||||||
|                 .FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id && |  | ||||||
|                                           c.Provider == provider && |  | ||||||
|                                           c.ProvidedIdentifier == userInfo.UserId); |  | ||||||
|  |  | ||||||
|             // If no connection exists, create one |  | ||||||
|             if (existingConnection != null) |  | ||||||
|             { |  | ||||||
|                 await db.AccountConnections |  | ||||||
|                     .Where(c => c.AccountId == existingAccount.Id && |  | ||||||
|                                 c.Provider == provider && |  | ||||||
|                                 c.ProvidedIdentifier == userInfo.UserId) |  | ||||||
|                     .ExecuteUpdateAsync(s => s |  | ||||||
|                         .SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant()) |  | ||||||
|                         .SetProperty(c => c.Meta, userInfo.ToMetadata())); |  | ||||||
|  |  | ||||||
|                 return existingAccount; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var connection = new AccountConnection |  | ||||||
|             { |  | ||||||
|                 AccountId = existingAccount.Id, |  | ||||||
|                 Provider = provider, |  | ||||||
|                 ProvidedIdentifier = userInfo.UserId!, |  | ||||||
|                 AccessToken = userInfo.AccessToken, |  | ||||||
|                 RefreshToken = userInfo.RefreshToken, |  | ||||||
|                 LastUsedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 Meta = userInfo.ToMetadata() |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             await db.AccountConnections.AddAsync(connection); |  | ||||||
|             await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|             return existingAccount; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Create new account using the AccountService |  | ||||||
|         var newAccount = await accounts.CreateAccount(userInfo); |  | ||||||
|  |  | ||||||
|         // Create the provider connection |  | ||||||
|         var newConnection = new AccountConnection |  | ||||||
|         { |  | ||||||
|             AccountId = newAccount.Id, |  | ||||||
|             Provider = provider, |  | ||||||
|             ProvidedIdentifier = userInfo.UserId!, |  | ||||||
|             AccessToken = userInfo.AccessToken, |  | ||||||
|             RefreshToken = userInfo.RefreshToken, |  | ||||||
|             LastUsedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|             Meta = userInfo.ToMetadata() |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         db.AccountConnections.Add(newConnection); |  | ||||||
|         await db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return newAccount; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,295 +0,0 @@ | |||||||
| using System.IdentityModel.Tokens.Jwt; |  | ||||||
| using System.Net.Http.Json; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
| using DysonNetwork.Sphere.Account; |  | ||||||
| using DysonNetwork.Sphere.Storage; |  | ||||||
| using Microsoft.EntityFrameworkCore; |  | ||||||
| using Microsoft.IdentityModel.Tokens; |  | ||||||
| using NodaTime; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Base service for OpenID Connect authentication providers |  | ||||||
| /// </summary> |  | ||||||
| public abstract class OidcService( |  | ||||||
|     IConfiguration configuration, |  | ||||||
|     IHttpClientFactory httpClientFactory, |  | ||||||
|     AppDatabase db, |  | ||||||
|     AuthService auth, |  | ||||||
|     ICacheService cache |  | ||||||
| ) |  | ||||||
| { |  | ||||||
|     protected readonly IConfiguration Configuration = configuration; |  | ||||||
|     protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory; |  | ||||||
|     protected readonly AppDatabase Db = db; |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets the unique identifier for this provider |  | ||||||
|     /// </summary> |  | ||||||
|     public abstract string ProviderName { get; } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets the OIDC discovery document endpoint |  | ||||||
|     /// </summary> |  | ||||||
|     protected abstract string DiscoveryEndpoint { get; } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets configuration section name for this provider |  | ||||||
|     /// </summary> |  | ||||||
|     protected abstract string ConfigSectionName { get; } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets the authorization URL for initiating the authentication flow |  | ||||||
|     /// </summary> |  | ||||||
|     public abstract string GetAuthorizationUrl(string state, string nonce); |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Process the callback from the OIDC provider |  | ||||||
|     /// </summary> |  | ||||||
|     public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData); |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Gets the provider configuration |  | ||||||
|     /// </summary> |  | ||||||
|     protected ProviderConfiguration GetProviderConfig() |  | ||||||
|     { |  | ||||||
|         return new ProviderConfiguration |  | ||||||
|         { |  | ||||||
|             ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "", |  | ||||||
|             ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "", |  | ||||||
|             RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower() |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Retrieves the OpenID Connect discovery document |  | ||||||
|     /// </summary> |  | ||||||
|     protected virtual async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync() |  | ||||||
|     { |  | ||||||
|         // Construct a cache key unique to the current provider: |  | ||||||
|         var cacheKey = $"oidc-discovery:{ProviderName}"; |  | ||||||
|  |  | ||||||
|         // Try getting the discovery document from cache first: |  | ||||||
|         var (found, cachedDoc) = await cache.GetAsyncWithStatus<OidcDiscoveryDocument>(cacheKey); |  | ||||||
|         if (found && cachedDoc != null) |  | ||||||
|         { |  | ||||||
|             return cachedDoc; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // If it's not cached, fetch from the actual discovery endpoint: |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|         var response = await client.GetAsync(DiscoveryEndpoint); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|         var doc = await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>(); |  | ||||||
|  |  | ||||||
|         // Store the discovery document in the cache for a while (e.g., 15 minutes): |  | ||||||
|         if (doc is not null) |  | ||||||
|             await cache.SetAsync(cacheKey, doc, TimeSpan.FromMinutes(15)); |  | ||||||
|  |  | ||||||
|         return doc; |  | ||||||
|  |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Exchange the authorization code for tokens |  | ||||||
|     /// </summary> |  | ||||||
|     protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code, |  | ||||||
|         string? codeVerifier = null) |  | ||||||
|     { |  | ||||||
|         var config = GetProviderConfig(); |  | ||||||
|         var discoveryDocument = await GetDiscoveryDocumentAsync(); |  | ||||||
|  |  | ||||||
|         if (discoveryDocument?.TokenEndpoint == null) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Token endpoint not found in discovery document"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var client = HttpClientFactory.CreateClient(); |  | ||||||
|         var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier)); |  | ||||||
|  |  | ||||||
|         var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         return await response.Content.ReadFromJsonAsync<OidcTokenResponse>(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Build the token request parameters |  | ||||||
|     /// </summary> |  | ||||||
|     protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config, |  | ||||||
|         string? codeVerifier) |  | ||||||
|     { |  | ||||||
|         var parameters = new Dictionary<string, string> |  | ||||||
|         { |  | ||||||
|             { "client_id", config.ClientId }, |  | ||||||
|             { "code", code }, |  | ||||||
|             { "grant_type", "authorization_code" }, |  | ||||||
|             { "redirect_uri", config.RedirectUri } |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrEmpty(config.ClientSecret)) |  | ||||||
|         { |  | ||||||
|             parameters.Add("client_secret", config.ClientSecret); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrEmpty(codeVerifier)) |  | ||||||
|         { |  | ||||||
|             parameters.Add("code_verifier", codeVerifier); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return parameters; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Validates and extracts information from an ID token |  | ||||||
|     /// </summary> |  | ||||||
|     protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken, |  | ||||||
|         TokenValidationParameters validationParameters) |  | ||||||
|     { |  | ||||||
|         var handler = new JwtSecurityTokenHandler(); |  | ||||||
|         handler.ValidateToken(idToken, validationParameters, out _); |  | ||||||
|  |  | ||||||
|         var jwtToken = handler.ReadJwtToken(idToken); |  | ||||||
|  |  | ||||||
|         // Extract standard claims |  | ||||||
|         var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; |  | ||||||
|         var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value; |  | ||||||
|         var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true"; |  | ||||||
|         var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value; |  | ||||||
|         var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value; |  | ||||||
|         var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value; |  | ||||||
|         var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value; |  | ||||||
|         var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value; |  | ||||||
|  |  | ||||||
|         // Determine preferred username - try different options |  | ||||||
|         var username = preferredUsername; |  | ||||||
|         if (string.IsNullOrEmpty(username)) |  | ||||||
|         { |  | ||||||
|             // Fall back to email local part if no preferred username |  | ||||||
|             username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return new OidcUserInfo |  | ||||||
|         { |  | ||||||
|             UserId = userId, |  | ||||||
|             Email = email, |  | ||||||
|             EmailVerified = emailVerified, |  | ||||||
|             FirstName = givenName ?? "", |  | ||||||
|             LastName = familyName ?? "", |  | ||||||
|             DisplayName = name ?? $"{givenName} {familyName}".Trim(), |  | ||||||
|             PreferredUsername = username ?? "", |  | ||||||
|             ProfilePictureUrl = picture, |  | ||||||
|             Provider = ProviderName |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Creates a challenge and session for an authenticated user |  | ||||||
|     /// Also creates or updates the account connection |  | ||||||
|     /// </summary> |  | ||||||
|     public async Task<Challenge> CreateChallengeForUserAsync( |  | ||||||
|         OidcUserInfo userInfo, |  | ||||||
|         Account.Account account, |  | ||||||
|         HttpContext request, |  | ||||||
|         string deviceId |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         // Create or update the account connection |  | ||||||
|         var connection = await Db.AccountConnections |  | ||||||
|             .FirstOrDefaultAsync(c => c.Provider == ProviderName && |  | ||||||
|                                       c.ProvidedIdentifier == userInfo.UserId && |  | ||||||
|                                       c.AccountId == account.Id |  | ||||||
|             ); |  | ||||||
|  |  | ||||||
|         if (connection is null) |  | ||||||
|         { |  | ||||||
|             connection = new AccountConnection |  | ||||||
|             { |  | ||||||
|                 Provider = ProviderName, |  | ||||||
|                 ProvidedIdentifier = userInfo.UserId ?? "", |  | ||||||
|                 AccessToken = userInfo.AccessToken, |  | ||||||
|                 RefreshToken = userInfo.RefreshToken, |  | ||||||
|                 LastUsedAt = SystemClock.Instance.GetCurrentInstant(), |  | ||||||
|                 AccountId = account.Id |  | ||||||
|             }; |  | ||||||
|             await Db.AccountConnections.AddAsync(connection); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Create a challenge that's already completed |  | ||||||
|         var now = SystemClock.Instance.GetCurrentInstant(); |  | ||||||
|         var challenge = new Challenge |  | ||||||
|         { |  | ||||||
|             ExpiredAt = now.Plus(Duration.FromHours(1)), |  | ||||||
|             StepTotal = await auth.DetectChallengeRisk(request.Request, account), |  | ||||||
|             Type = ChallengeType.Oidc, |  | ||||||
|             Platform = ChallengePlatform.Unidentified, |  | ||||||
|             Audiences = [ProviderName], |  | ||||||
|             Scopes = ["*"], |  | ||||||
|             AccountId = account.Id, |  | ||||||
|             DeviceId = deviceId, |  | ||||||
|             IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null, |  | ||||||
|             UserAgent = request.Request.Headers.UserAgent, |  | ||||||
|         }; |  | ||||||
|         challenge.StepRemain--; |  | ||||||
|         if (challenge.StepRemain < 0) challenge.StepRemain = 0; |  | ||||||
|  |  | ||||||
|         await Db.AuthChallenges.AddAsync(challenge); |  | ||||||
|         await Db.SaveChangesAsync(); |  | ||||||
|  |  | ||||||
|         return challenge; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Provider configuration from app settings |  | ||||||
| /// </summary> |  | ||||||
| public class ProviderConfiguration |  | ||||||
| { |  | ||||||
|     public string ClientId { get; set; } = ""; |  | ||||||
|     public string ClientSecret { get; set; } = ""; |  | ||||||
|     public string RedirectUri { get; set; } = ""; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// OIDC Discovery Document |  | ||||||
| /// </summary> |  | ||||||
| public class OidcDiscoveryDocument |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("authorization_endpoint")] |  | ||||||
|     public string? AuthorizationEndpoint { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("userinfo_endpoint")] |  | ||||||
|     public string? UserinfoEndpoint { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Response from the token endpoint |  | ||||||
| /// </summary> |  | ||||||
| public class OidcTokenResponse |  | ||||||
| { |  | ||||||
|     [JsonPropertyName("access_token")] public string? AccessToken { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("token_type")] public string? TokenType { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } |  | ||||||
|  |  | ||||||
|     [JsonPropertyName("id_token")] public string? IdToken { get; set; } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Data received in the callback from an OIDC provider |  | ||||||
| /// </summary> |  | ||||||
| public class OidcCallbackData |  | ||||||
| { |  | ||||||
|     public string Code { get; set; } = ""; |  | ||||||
|     public string IdToken { get; set; } = ""; |  | ||||||
|     public string? State { get; set; } |  | ||||||
|     public string? RawData { get; set; } |  | ||||||
| } |  | ||||||
| @@ -1,189 +0,0 @@ | |||||||
| using System.Text.Json; |  | ||||||
| using System.Text.Json.Serialization; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Represents the state parameter used in OpenID Connect flows. |  | ||||||
| /// Handles serialization and deserialization of the state parameter. |  | ||||||
| /// </summary> |  | ||||||
| public class OidcState |  | ||||||
| { |  | ||||||
|     /// <summary> |  | ||||||
|     /// The type of OIDC flow (login or connect). |  | ||||||
|     /// </summary> |  | ||||||
|     public OidcFlowType FlowType { get; set; } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The account ID (for connect flow). |  | ||||||
|     /// </summary> |  | ||||||
|     public Guid? AccountId { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The OIDC provider name. |  | ||||||
|     /// </summary> |  | ||||||
|     public string? Provider { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The nonce for CSRF protection. |  | ||||||
|     /// </summary> |  | ||||||
|     public string? Nonce { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The device ID for the authentication request. |  | ||||||
|     /// </summary> |  | ||||||
|     public string? DeviceId { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The return URL after authentication (for login flow). |  | ||||||
|     /// </summary> |  | ||||||
|     public string? ReturnUrl { get; set; } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Creates a new OidcState for a connection flow. |  | ||||||
|     /// </summary> |  | ||||||
|     public static OidcState ForConnection(Guid accountId, string provider, string nonce, string? deviceId = null) |  | ||||||
|     { |  | ||||||
|         return new OidcState |  | ||||||
|         { |  | ||||||
|             FlowType = OidcFlowType.Connect, |  | ||||||
|             AccountId = accountId, |  | ||||||
|             Provider = provider, |  | ||||||
|             Nonce = nonce, |  | ||||||
|             DeviceId = deviceId |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Creates a new OidcState for a login flow. |  | ||||||
|     /// </summary> |  | ||||||
|     public static OidcState ForLogin(string returnUrl = "/", string? deviceId = null) |  | ||||||
|     { |  | ||||||
|         return new OidcState |  | ||||||
|         { |  | ||||||
|             FlowType = OidcFlowType.Login, |  | ||||||
|             ReturnUrl = returnUrl, |  | ||||||
|             DeviceId = deviceId |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// The version of the state format. |  | ||||||
|     /// </summary> |  | ||||||
|     public int Version { get; set; } = 1; |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Serializes the state to a JSON string for use in OIDC flows. |  | ||||||
|     /// </summary> |  | ||||||
|     public string Serialize() |  | ||||||
|     { |  | ||||||
|         return JsonSerializer.Serialize(this, new JsonSerializerOptions |  | ||||||
|         { |  | ||||||
|             PropertyNamingPolicy = JsonNamingPolicy.CamelCase, |  | ||||||
|             DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull |  | ||||||
|         }); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Attempts to parse a state string into an OidcState object. |  | ||||||
|     /// </summary> |  | ||||||
|     public static bool TryParse(string? stateString, out OidcState? state) |  | ||||||
|     { |  | ||||||
|         state = null; |  | ||||||
|  |  | ||||||
|         if (string.IsNullOrEmpty(stateString)) |  | ||||||
|             return false; |  | ||||||
|  |  | ||||||
|         try |  | ||||||
|         { |  | ||||||
|             // First try to parse as JSON |  | ||||||
|             try |  | ||||||
|             { |  | ||||||
|                 state = JsonSerializer.Deserialize<OidcState>(stateString); |  | ||||||
|                 return state != null; |  | ||||||
|             } |  | ||||||
|             catch (JsonException) |  | ||||||
|             { |  | ||||||
|                 // Not a JSON string, try legacy format for backward compatibility |  | ||||||
|                 return TryParseLegacyFormat(stateString, out state); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         catch |  | ||||||
|         { |  | ||||||
|             return false; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     private static bool TryParseLegacyFormat(string stateString, out OidcState? state) |  | ||||||
|     { |  | ||||||
|         state = null; |  | ||||||
|         var parts = stateString.Split('|'); |  | ||||||
|  |  | ||||||
|         // Check for connection flow format: {accountId}|{provider}|{nonce}|{deviceId}|connect |  | ||||||
|         if (parts.Length >= 5 && |  | ||||||
|             Guid.TryParse(parts[0], out var accountId) && |  | ||||||
|             string.Equals(parts[^1], "connect", StringComparison.OrdinalIgnoreCase)) |  | ||||||
|         { |  | ||||||
|             state = new OidcState |  | ||||||
|             { |  | ||||||
|                 FlowType = OidcFlowType.Connect, |  | ||||||
|                 AccountId = accountId, |  | ||||||
|                 Provider = parts[1], |  | ||||||
|                 Nonce = parts[2], |  | ||||||
|                 DeviceId = parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]) ? parts[3] : null |  | ||||||
|             }; |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Check for login flow format: {returnUrl}|{deviceId}|login |  | ||||||
|         if (parts.Length >= 2 && |  | ||||||
|             parts.Length <= 3 && |  | ||||||
|             (parts.Length < 3 || string.Equals(parts[^1], "login", StringComparison.OrdinalIgnoreCase))) |  | ||||||
|         { |  | ||||||
|             state = new OidcState |  | ||||||
|             { |  | ||||||
|                 FlowType = OidcFlowType.Login, |  | ||||||
|                 ReturnUrl = parts[0], |  | ||||||
|                 DeviceId = parts.Length >= 2 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : null |  | ||||||
|             }; |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Legacy format support (for backward compatibility) |  | ||||||
|         if (parts.Length == 1) |  | ||||||
|         { |  | ||||||
|             state = new OidcState |  | ||||||
|             { |  | ||||||
|                 FlowType = OidcFlowType.Login, |  | ||||||
|                 ReturnUrl = parts[0], |  | ||||||
|                 DeviceId = null |  | ||||||
|             }; |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Represents the type of OIDC flow. |  | ||||||
| /// </summary> |  | ||||||
| public enum OidcFlowType |  | ||||||
| { |  | ||||||
|     /// <summary> |  | ||||||
|     /// Login or registration flow. |  | ||||||
|     /// </summary> |  | ||||||
|     Login, |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Account connection flow. |  | ||||||
|     /// </summary> |  | ||||||
|     Connect |  | ||||||
| } |  | ||||||
| @@ -1,49 +0,0 @@ | |||||||
| namespace DysonNetwork.Sphere.Auth.OpenId; |  | ||||||
|  |  | ||||||
| /// <summary> |  | ||||||
| /// Represents the user information from an OIDC provider |  | ||||||
| /// </summary> |  | ||||||
| public class OidcUserInfo |  | ||||||
| { |  | ||||||
|     public string? UserId { get; set; } |  | ||||||
|     public string? Email { get; set; } |  | ||||||
|     public bool EmailVerified { get; set; } |  | ||||||
|     public string FirstName { get; set; } = ""; |  | ||||||
|     public string LastName { get; set; } = ""; |  | ||||||
|     public string DisplayName { get; set; } = ""; |  | ||||||
|     public string PreferredUsername { get; set; } = ""; |  | ||||||
|     public string? ProfilePictureUrl { get; set; } |  | ||||||
|     public string Provider { get; set; } = ""; |  | ||||||
|     public string? RefreshToken { get; set; } |  | ||||||
|     public string? AccessToken { get; set; } |  | ||||||
|  |  | ||||||
|     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(FirstName)) |  | ||||||
|             metadata["first_name"] = FirstName; |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(LastName)) |  | ||||||
|             metadata["last_name"] = LastName; |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(DisplayName)) |  | ||||||
|             metadata["display_name"] = DisplayName; |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(PreferredUsername)) |  | ||||||
|             metadata["preferred_username"] = PreferredUsername; |  | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(ProfilePictureUrl)) |  | ||||||
|             metadata["profile_picture_url"] = ProfilePictureUrl; |  | ||||||
|  |  | ||||||
|         return metadata; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user