From 6aed05af06f2feac552ae8292f5a5ed474b10d67 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 02:31:05 +0200 Subject: [PATCH 1/4] feat(core): add optional SQL query logging --- Foxchat.Chat/Database/ChatContext.cs | 10 +++++++--- Foxchat.Chat/Program.cs | 3 ++- Foxchat.Chat/chat.ini | 11 +++++++--- Foxchat.Core/CoreConfig.cs | 13 ++++++++---- .../Extensions/ServiceCollectionExtensions.cs | 20 ++++++++++--------- Foxchat.Identity/Database/IdentityContext.cs | 14 ++++++++----- Foxchat.Identity/Program.cs | 3 ++- Foxchat.Identity/identity.ini | 13 +++++++----- 8 files changed, 56 insertions(+), 31 deletions(-) diff --git a/Foxchat.Chat/Database/ChatContext.cs b/Foxchat.Chat/Database/ChatContext.cs index efbe8a1..f8ac2a5 100644 --- a/Foxchat.Chat/Database/ChatContext.cs +++ b/Foxchat.Chat/Database/ChatContext.cs @@ -1,6 +1,7 @@ using Foxchat.Chat.Database.Models; using Foxchat.Core; using Foxchat.Core.Database; +using Foxchat.Core.Extensions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Npgsql; @@ -10,6 +11,7 @@ namespace Foxchat.Chat.Database; public class ChatContext : IDatabaseContext { private readonly NpgsqlDataSource _dataSource; + private readonly ILoggerFactory? _loggerFactory; public override DbSet Instance { get; set; } public DbSet IdentityInstances { get; set; } @@ -18,7 +20,7 @@ public class ChatContext : IDatabaseContext public DbSet Channels { get; set; } public DbSet Messages { get; set; } - public ChatContext(InstanceConfig config) + public ChatContext(InstanceConfig config, ILoggerFactory? loggerFactory) { var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { @@ -29,12 +31,14 @@ public class ChatContext : IDatabaseContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); dataSourceBuilder.UseNodaTime(); _dataSource = dataSourceBuilder.Build(); + _loggerFactory = loggerFactory; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder .UseNpgsql(_dataSource, o => o.UseNodaTime()) - .UseSnakeCaseNamingConvention(); + .UseSnakeCaseNamingConvention() + .UseLoggerFactory(_loggerFactory); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -73,6 +77,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory() ?? new(); - return new ChatContext(config); + return new ChatContext(config, null); } } \ No newline at end of file diff --git a/Foxchat.Chat/Program.cs b/Foxchat.Chat/Program.cs index 263167d..fa2bbb6 100644 --- a/Foxchat.Chat/Program.cs +++ b/Foxchat.Chat/Program.cs @@ -4,13 +4,14 @@ using Foxchat.Core; using Foxchat.Chat; using Foxchat.Chat.Database; using Foxchat.Chat.Extensions; +using Foxchat.Core.Extensions; using Newtonsoft.Json; var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration("chat.ini"); -builder.AddSerilog(config.LogEventLevel); +builder.AddSerilog(); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Chat {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); diff --git a/Foxchat.Chat/chat.ini b/Foxchat.Chat/chat.ini index 21a32a0..906acdb 100644 --- a/Foxchat.Chat/chat.ini +++ b/Foxchat.Chat/chat.ini @@ -2,9 +2,6 @@ Host = localhost Port = 7610 Domain = chat.fox.localhost -; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal -LogEventLevel = Debug - [Database] ; The database URL in ADO.NET format. Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=password" @@ -13,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=passwor Timeout = 5 ; The maximum number of open connections. Defaults to 50. MaxPoolSize = 500 + +[Logging] +; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal +LogEventLevel = Debug +; Whether to log SQL queries. +LogQueries = true +; Optional logging to Seq +SeqLogUrl = http://localhost:5341 \ No newline at end of file diff --git a/Foxchat.Core/CoreConfig.cs b/Foxchat.Core/CoreConfig.cs index 40a97b4..236af8a 100644 --- a/Foxchat.Core/CoreConfig.cs +++ b/Foxchat.Core/CoreConfig.cs @@ -11,9 +11,7 @@ public class CoreConfig public string Address => $"{(Secure ? "https" : "http")}://{Host}:{Port}"; - public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; - public string? SeqLogUrl { get; set; } - + public LoggingConfig Logging { get; set; } = new(); public DatabaseConfig Database { get; set; } = new(); public class DatabaseConfig @@ -22,4 +20,11 @@ public class CoreConfig public int? Timeout { get; set; } public int? MaxPoolSize { get; set; } } -} + + public class LoggingConfig + { + public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; + public string? SeqLogUrl { get; set; } + public bool LogQueries { get; set; } = false; + } +} \ No newline at end of file diff --git a/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs index f5d4893..a41dd0b 100644 --- a/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs @@ -7,30 +7,32 @@ using NodaTime; using Serilog; using Serilog.Events; -namespace Foxchat.Core; +namespace Foxchat.Core.Extensions; public static class ServiceCollectionExtensions { /// /// Adds Serilog to this service collection. This method also initializes Serilog so it should be called as early as possible, before any log calls. /// - public static void AddSerilog(this WebApplicationBuilder builder, LogEventLevel level) + public static void AddSerilog(this WebApplicationBuilder builder) { var config = builder.Configuration.Get() ?? new(); var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() - .MinimumLevel.Is(level) + .MinimumLevel.Is(config.Logging.LogEventLevel) // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. - // Serilog doesn't disable the built in logs so we do it here. + // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", + config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) .WriteTo.Console(); - if (config.SeqLogUrl != null) - logCfg.WriteTo.Seq(config.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose); + if (config.Logging.SeqLogUrl != null) + logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose); Log.Logger = logCfg.CreateLogger(); @@ -54,9 +56,9 @@ public static class ServiceCollectionExtensions return services; } - public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null) where T : class, new() + public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null) + where T : class, new() { - builder.Configuration.Sources.Clear(); builder.Configuration.AddConfiguration(configFile); @@ -76,4 +78,4 @@ public static class ServiceCollectionExtensions .AddIniFile(file, optional: false, reloadOnChange: true) .AddEnvironmentVariables(); } -} +} \ No newline at end of file diff --git a/Foxchat.Identity/Database/IdentityContext.cs b/Foxchat.Identity/Database/IdentityContext.cs index e983fac..efc4850 100644 --- a/Foxchat.Identity/Database/IdentityContext.cs +++ b/Foxchat.Identity/Database/IdentityContext.cs @@ -1,5 +1,6 @@ using Foxchat.Core; using Foxchat.Core.Database; +using Foxchat.Core.Extensions; using Foxchat.Identity.Database.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; @@ -10,6 +11,7 @@ namespace Foxchat.Identity.Database; public class IdentityContext : IDatabaseContext { private readonly NpgsqlDataSource _dataSource; + private readonly ILoggerFactory? _loggerFactory; public override DbSet Instance { get; set; } public DbSet Accounts { get; set; } @@ -18,7 +20,7 @@ public class IdentityContext : IDatabaseContext public DbSet Tokens { get; set; } public DbSet GuildAccounts { get; set; } - public IdentityContext(InstanceConfig config) + public IdentityContext(InstanceConfig config, ILoggerFactory? loggerFactory) { var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { @@ -29,12 +31,14 @@ public class IdentityContext : IDatabaseContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); dataSourceBuilder.UseNodaTime(); _dataSource = dataSourceBuilder.Build(); + _loggerFactory = loggerFactory; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder - .UseNpgsql(_dataSource, o => o.UseNodaTime()) - .UseSnakeCaseNamingConvention(); + => optionsBuilder + .UseNpgsql(_dataSource, o => o.UseNodaTime()) + .UseSnakeCaseNamingConvention() + .UseLoggerFactory(_loggerFactory); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -67,6 +71,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory() ?? new(); - return new IdentityContext(config); + return new IdentityContext(config, null); } } diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs index 8e0c47e..035feb1 100644 --- a/Foxchat.Identity/Program.cs +++ b/Foxchat.Identity/Program.cs @@ -1,6 +1,7 @@ using Newtonsoft.Json.Serialization; using Serilog; using Foxchat.Core; +using Foxchat.Core.Extensions; using Foxchat.Identity; using Foxchat.Identity.Database; using Foxchat.Identity.Services; @@ -11,7 +12,7 @@ var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration("identity.ini"); -builder.AddSerilog(config.LogEventLevel); +builder.AddSerilog(); await BuildInfo.ReadBuildInfo(); Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); diff --git a/Foxchat.Identity/identity.ini b/Foxchat.Identity/identity.ini index d4b3c40..7f4172c 100644 --- a/Foxchat.Identity/identity.ini +++ b/Foxchat.Identity/identity.ini @@ -2,11 +2,6 @@ Host = localhost Port = 7611 Domain = id.fox.localhost -; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal -LogEventLevel = Debug -; Optional logging to Seq -SeqLogUrl = http://localhost:5341 - [Database] ; The database URL in ADO.NET format. Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=password" @@ -15,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=passwo Timeout = 5 ; The maximum number of open connections. Defaults to 50. MaxPoolSize = 500 + +[Logging] +; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal +LogEventLevel = Debug +; Whether to log SQL queries. +LogQueries = true +; Optional logging to Seq +SeqLogUrl = http://localhost:5341 From 00a54f4f8bcc80d0ec52308091bea69d531e19dc Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 17:17:36 +0200 Subject: [PATCH 2/4] feat(chat): add /guilds/{id} and /guilds/@me endpoints --- .../Controllers/Api/GuildsController.cs | 47 +++++++++++++++++-- .../ServerAuthenticationMiddleware.cs | 7 +++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Foxchat.Chat/Controllers/Api/GuildsController.cs b/Foxchat.Chat/Controllers/Api/GuildsController.cs index ab99a6d..bd8d0bb 100644 --- a/Foxchat.Chat/Controllers/Api/GuildsController.cs +++ b/Foxchat.Chat/Controllers/Api/GuildsController.cs @@ -5,6 +5,7 @@ using Foxchat.Chat.Services; using Foxchat.Core.Models; using Foxchat.Core.Models.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using ApiError = Foxchat.Core.ApiError; namespace Foxchat.Chat.Controllers.Api; @@ -16,10 +17,9 @@ public class GuildsController(ILogger logger, ChatContext db, UserResolverServic [HttpPost] public async Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) { - var (instance, sig) = HttpContext.GetSignatureOrThrow(); - if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID."); + var (instance, _, userId) = HttpContext.GetSignatureWithUser(); - var user = await userResolverService.ResolveUserAsync(instance, sig.UserId); + var user = await userResolverService.ResolveUserAsync(instance, userId); var guild = new Guild { @@ -38,10 +38,47 @@ public class GuildsController(ILogger logger, ChatContext db, UserResolverServic await db.SaveChangesAsync(); return Ok(new Guilds.Guild( - guild.Id.ToString(), - guild.Name, + guild.Id.ToString(), + guild.Name, [user.Id.ToString()], [new Channels.PartialChannel(defaultChannel.Id.ToString(), defaultChannel.Name)]) ); } + + [HttpGet("{id}")] + public async Task GetGuild(Ulid id) + { + var (instance, _, userId) = HttpContext.GetSignatureWithUser(); + var guild = await db.Guilds + .Include(g => g.Channels) + .FirstOrDefaultAsync(g => + g.Id == id && g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id)); + if (guild == null) throw new ApiError.NotFound("Guild not found"); + + return Ok(new Guilds.Guild( + guild.Id.ToString(), + guild.Name, + [guild.OwnerId.ToString()], + guild.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name)) + )); + } + + [HttpGet("@me")] + public async Task GetUserGuilds() + { + var (instance, _, userId) = HttpContext.GetSignatureWithUser(); + var guilds = await db.Guilds + .Include(g => g.Channels) + .Where(g => g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id)) + .ToListAsync(); + + var guildResponses = guilds.Select(g => new Guilds.Guild( + g.Id.ToString(), + g.Name, + [g.OwnerId.ToString()], + g.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name)) + )); + + return Ok(guildResponses); + } } \ No newline at end of file diff --git a/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs index daea8dc..4db585a 100644 --- a/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs +++ b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs @@ -76,4 +76,11 @@ public static class HttpContextExtensions return ((IdentityInstance, SignatureData))obj!; } + + public static (IdentityInstance, SignatureData, string) GetSignatureWithUser(this HttpContext ctx) + { + var (instance, sig) = ctx.GetSignatureOrThrow(); + if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID."); + return (instance, sig, sig.UserId); + } } \ No newline at end of file From 8bd118ea670e4f331cd3223b4ef08a6b6c820509 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 17:19:45 +0200 Subject: [PATCH 3/4] refactor(identity): change receiver of OauthUtils.ExpandScopes() --- .../Controllers/Oauth/PasswordAuthController.cs | 4 ++-- .../Controllers/Oauth/TokenController.cs | 6 +++--- .../Middleware/ClientAuthorizationMiddleware.cs | 4 ++-- Foxchat.Identity/Utils/OauthUtils.cs | 13 ++++++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs index a4f5080..06a6a6e 100644 --- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs +++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs @@ -25,7 +25,7 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null - var appScopes = appToken.ExpandScopes(); + var appScopes = appToken.Scopes.ExpandScopes(); if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", @@ -54,7 +54,7 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c { var app = HttpContext.GetApplicationOrThrow(); var appToken = HttpContext.GetToken() ?? throw new UnreachableException(); - var appScopes = appToken.ExpandScopes(); + var appScopes = appToken.Scopes.ExpandScopes(); if (req.Scopes.Except(appScopes).Any()) throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token", diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs index ed7dfc8..20c5924 100644 --- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs +++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs @@ -15,7 +15,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : public async Task PostToken([FromBody] PostTokenRequest req) { var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret); - var appScopes = app.ExpandScopes(); + var appScopes = app.Scopes.ExpandScopes(); var scopes = req.Scope.Split(' '); if (scopes.Except(appScopes).Any()) @@ -25,9 +25,9 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) : switch (req.GrantType) { - case "client_credentials": + case OauthUtils.ClientCredentials: return await HandleClientCredentialsAsync(app, scopes); - case "authorization_code": + case OauthUtils.AuthorizationCode: // TODO break; default: diff --git a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs index 701fb05..2e6499d 100644 --- a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs +++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs @@ -24,8 +24,8 @@ public class ClientAuthorizationMiddleware( var token = ctx.GetToken(); if (token == null || token.Expires < clock.GetCurrentInstant()) throw new ApiError.Unauthorized("This endpoint requires an authenticated user."); - if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.ExpandScopes()).Any()) - throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes())); + if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) + throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes.ExpandScopes())); await next(ctx); } diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs index bf698a2..d6d5b2c 100644 --- a/Foxchat.Identity/Utils/OauthUtils.cs +++ b/Foxchat.Identity/Utils/OauthUtils.cs @@ -6,7 +6,10 @@ namespace Foxchat.Identity.Utils; public static class OauthUtils { - public static readonly string[] Scopes = ["identify", "chat_client"]; + public const string ClientCredentials = "client_credentials"; + public const string AuthorizationCode = "authorization_code"; + + public static readonly string[] Scopes = ["identify", "email", "guilds", "chat_client"]; private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; private const string OobUri = "urn:ietf:wg:oauth:2.0:oob"; @@ -25,11 +28,7 @@ public static class OauthUtils } } - public static string[] ExpandScopes(this Token token) => token.Scopes.Contains("chat_client") + public static string[] ExpandScopes(this string[] scopes) => scopes.Contains("chat_client") ? Scopes - : token.Scopes; - - public static string[] ExpandScopes(this Application app) => app.Scopes.Contains("chat_client") - ? Scopes - : app.Scopes; + : scopes; } \ No newline at end of file From 291941311808cbde7c937cc4e48dc4e20bdae413 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 22 May 2024 17:20:00 +0200 Subject: [PATCH 4/4] feat(identity): add /users/@me endpoint --- .../Controllers/UsersController.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Foxchat.Identity/Controllers/UsersController.cs b/Foxchat.Identity/Controllers/UsersController.cs index 9e9c32d..6302183 100644 --- a/Foxchat.Identity/Controllers/UsersController.cs +++ b/Foxchat.Identity/Controllers/UsersController.cs @@ -1,12 +1,17 @@ using Foxchat.Core; using Foxchat.Core.Models; using Foxchat.Identity.Database; +using Foxchat.Identity.Database.Models; +using Foxchat.Identity.Middleware; +using Foxchat.Identity.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; namespace Foxchat.Identity.Controllers; [ApiController] +[ClientAuthenticate] [Route("/_fox/ident/users")] public class UsersController(ILogger logger, InstanceConfig config, IdentityContext db) : ControllerBase { @@ -18,4 +23,30 @@ public class UsersController(ILogger logger, InstanceConfig config, IdentityCont return Ok(new Users.User(user.Id.ToString(), user.Username, config.Domain, null)); } + + [HttpGet("@me")] + [Authorize("identify")] + public IActionResult GetMe() + { + var acct = HttpContext.GetAccountOrThrow(); + var token = HttpContext.GetToken()!; + var showEmail = token.Scopes.ExpandScopes().Contains("email"); + + return Ok(new MeUser( + acct.Id, + acct.Username, + acct.Role, + null, + showEmail ? acct.Email : null + )); + } + + public record MeUser( + Ulid Id, + string Username, + Account.AccountRole Role, + string? AvatarUrl, + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? Email + ); } \ No newline at end of file