💥 Rename Pusher to Ring
This commit is contained in:
		
							
								
								
									
										178
									
								
								DysonNetwork.Ring/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								DysonNetwork.Ring/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| using System.Linq.Expressions; | ||||
| using System.Reflection; | ||||
| using DysonNetwork.Ring.Notification; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Design; | ||||
| using Microsoft.EntityFrameworkCore.Query; | ||||
| using NodaTime; | ||||
| using Quartz; | ||||
|  | ||||
| namespace DysonNetwork.Ring; | ||||
|  | ||||
| public class AppDatabase( | ||||
|     DbContextOptions<AppDatabase> options, | ||||
|     IConfiguration configuration | ||||
| ) : DbContext(options) | ||||
| { | ||||
|     public DbSet<Notification.Notification> Notifications { get; set; } = null!; | ||||
|     public DbSet<PushSubscription> PushSubscriptions { get; set; } = null!; | ||||
|  | ||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||
|     { | ||||
|         optionsBuilder.UseNpgsql( | ||||
|             configuration.GetConnectionString("App"), | ||||
|             opt => opt | ||||
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) | ||||
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) | ||||
|                 .UseNodaTime() | ||||
|         ).UseSnakeCaseNamingConvention(); | ||||
|  | ||||
|         base.OnConfiguring(optionsBuilder); | ||||
|     } | ||||
|  | ||||
|     protected override void OnModelCreating(ModelBuilder modelBuilder) | ||||
|     { | ||||
|         base.OnModelCreating(modelBuilder); | ||||
|  | ||||
|         // Automatically apply soft-delete filter to all entities inheriting BaseModel | ||||
|         foreach (var entityType in modelBuilder.Model.GetEntityTypes()) | ||||
|         { | ||||
|             if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue; | ||||
|             var method = typeof(AppDatabase) | ||||
|                 .GetMethod(nameof(SetSoftDeleteFilter), | ||||
|                     BindingFlags.NonPublic | BindingFlags.Static)! | ||||
|                 .MakeGenericMethod(entityType.ClrType); | ||||
|  | ||||
|             method.Invoke(null, [modelBuilder]); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder) | ||||
|         where TEntity : ModelBase | ||||
|     { | ||||
|         modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null); | ||||
|     } | ||||
|  | ||||
|     public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|  | ||||
|         foreach (var entry in ChangeTracker.Entries<ModelBase>()) | ||||
|         { | ||||
|             switch (entry.State) | ||||
|             { | ||||
|                 case EntityState.Added: | ||||
|                     entry.Entity.CreatedAt = now; | ||||
|                     entry.Entity.UpdatedAt = now; | ||||
|                     break; | ||||
|                 case EntityState.Modified: | ||||
|                     entry.Entity.UpdatedAt = now; | ||||
|                     break; | ||||
|                 case EntityState.Deleted: | ||||
|                     entry.State = EntityState.Modified; | ||||
|                     entry.Entity.DeletedAt = now; | ||||
|                     break; | ||||
|                 case EntityState.Detached: | ||||
|                 case EntityState.Unchanged: | ||||
|                 default: | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return await base.SaveChangesAsync(cancellationToken); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob | ||||
| { | ||||
|     public async Task Execute(IJobExecutionContext context) | ||||
|     { | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|  | ||||
|         logger.LogInformation("Deleting soft-deleted records..."); | ||||
|  | ||||
|         var threshold = now - Duration.FromDays(7); | ||||
|  | ||||
|         var entityTypes = db.Model.GetEntityTypes() | ||||
|             .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase)) | ||||
|             .Select(t => t.ClrType); | ||||
|  | ||||
|         foreach (var entityType in entityTypes) | ||||
|         { | ||||
|             var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)! | ||||
|                 .MakeGenericMethod(entityType).Invoke(db, null)!; | ||||
|             var parameter = Expression.Parameter(entityType, "e"); | ||||
|             var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt)); | ||||
|             var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?))); | ||||
|             var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?))); | ||||
|             var finalCondition = Expression.AndAlso(notNull, condition); | ||||
|             var lambda = Expression.Lambda(finalCondition, parameter); | ||||
|  | ||||
|             var queryable = set.Provider.CreateQuery( | ||||
|                 Expression.Call( | ||||
|                     typeof(Queryable), | ||||
|                     "Where", | ||||
|                     [entityType], | ||||
|                     set.Expression, | ||||
|                     Expression.Quote(lambda) | ||||
|                 ) | ||||
|             ); | ||||
|  | ||||
|             var toListAsync = typeof(EntityFrameworkQueryableExtensions) | ||||
|                 .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))! | ||||
|                 .MakeGenericMethod(entityType); | ||||
|  | ||||
|             var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!; | ||||
|             db.RemoveRange(items); | ||||
|         } | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase> | ||||
| { | ||||
|     public AppDatabase CreateDbContext(string[] args) | ||||
|     { | ||||
|         var configuration = new ConfigurationBuilder() | ||||
|             .SetBasePath(Directory.GetCurrentDirectory()) | ||||
|             .AddJsonFile("appsettings.json") | ||||
|             .Build(); | ||||
|  | ||||
|         var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>(); | ||||
|         return new AppDatabase(optionsBuilder.Options, configuration); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public static class OptionalQueryExtensions | ||||
| { | ||||
|     public static IQueryable<T> If<T>( | ||||
|         this IQueryable<T> source, | ||||
|         bool condition, | ||||
|         Func<IQueryable<T>, IQueryable<T>> transform | ||||
|     ) | ||||
|     { | ||||
|         return condition ? transform(source) : source; | ||||
|     } | ||||
|  | ||||
|     public static IQueryable<T> If<T, TP>( | ||||
|         this IIncludableQueryable<T, TP> source, | ||||
|         bool condition, | ||||
|         Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform | ||||
|     ) | ||||
|         where T : class | ||||
|     { | ||||
|         return condition ? transform(source) : source; | ||||
|     } | ||||
|  | ||||
|     public static IQueryable<T> If<T, TP>( | ||||
|         this IIncludableQueryable<T, IEnumerable<TP>> source, | ||||
|         bool condition, | ||||
|         Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform | ||||
|     ) | ||||
|         where T : class | ||||
|     { | ||||
|         return condition ? transform(source) : source; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										45
									
								
								DysonNetwork.Ring/Connection/ClientTypeMiddleware.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								DysonNetwork.Ring/Connection/ClientTypeMiddleware.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
|  | ||||
| public class ClientTypeMiddleware(RequestDelegate next) | ||||
| { | ||||
|     public async Task Invoke(HttpContext context) | ||||
|     { | ||||
|         var headers = context.Request.Headers; | ||||
|         bool isWebPage; | ||||
|  | ||||
|         // Priority 1: Check for custom header | ||||
|         if (headers.TryGetValue("X-Client", out var clientType)) | ||||
|         { | ||||
|             isWebPage = clientType.ToString().Length == 0; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             var userAgent = headers.UserAgent.ToString(); | ||||
|             var accept = headers.Accept.ToString(); | ||||
|  | ||||
|             // Priority 2: Check known app User-Agent (backward compatibility) | ||||
|             if (!string.IsNullOrEmpty(userAgent) && userAgent.Contains("Solian")) | ||||
|                 isWebPage = false; | ||||
|             // Priority 3: Accept header can help infer intent | ||||
|             else if (!string.IsNullOrEmpty(accept) && accept.Contains("text/html")) | ||||
|                 isWebPage = true; | ||||
|             else if (!string.IsNullOrEmpty(accept) && accept.Contains("application/json")) | ||||
|                 isWebPage = false; | ||||
|             else | ||||
|                 isWebPage = true; | ||||
|         } | ||||
|  | ||||
|         context.Items["IsWebPage"] = isWebPage; | ||||
|  | ||||
|         var redirectWhiteList = new[] { "/ws", "/.well-known", "/swagger" }; | ||||
|         if(redirectWhiteList.Any(w => context.Request.Path.StartsWithSegments(w))) | ||||
|             await next(context); | ||||
|         else if (!isWebPage && !context.Request.Path.StartsWithSegments("/api")) | ||||
|             context.Response.Redirect( | ||||
|                 $"/api{context.Request.Path.Value}{context.Request.QueryString.Value}", | ||||
|                 permanent: false | ||||
|             ); | ||||
|         else | ||||
|             await next(context); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										17
									
								
								DysonNetwork.Ring/Connection/IWebSocketPacketHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								DysonNetwork.Ring/Connection/IWebSocketPacketHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| using System.Net.WebSockets; | ||||
| using DysonNetwork.Shared.Proto; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
|  | ||||
| public interface IWebSocketPacketHandler | ||||
| { | ||||
|     string PacketType { get; } | ||||
|  | ||||
|     Task HandleAsync( | ||||
|         Account currentUser, | ||||
|         string deviceId, | ||||
|         WebSocketPacket packet, | ||||
|         WebSocket socket, | ||||
|         WebSocketService srv | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										122
									
								
								DysonNetwork.Ring/Connection/WebSocketController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								DysonNetwork.Ring/Connection/WebSocketController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | ||||
| using System.Net.WebSockets; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Swashbuckle.AspNetCore.Annotations; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
|  | ||||
| [ApiController] | ||||
| public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase | ||||
| { | ||||
|     [Route("/ws")] | ||||
|     [Authorize] | ||||
|     [SwaggerIgnore] | ||||
|     public async Task TheGateway() | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); | ||||
|         if (currentUserValue is not Account currentUser || | ||||
|             currentSessionValue is not AuthSession currentSession) | ||||
|         { | ||||
|             HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var accountId = currentUser.Id!; | ||||
|         var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString(); | ||||
|  | ||||
|         if (string.IsNullOrEmpty(deviceId)) | ||||
|         { | ||||
|             HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext | ||||
|             { KeepAliveInterval = TimeSpan.FromSeconds(60) }); | ||||
|         var cts = new CancellationTokenSource(); | ||||
|         var connectionKey = (accountId, deviceId); | ||||
|  | ||||
|         if (!ws.TryAdd(connectionKey, webSocket, cts)) | ||||
|         { | ||||
|             await webSocket.SendAsync( | ||||
|                 new ArraySegment<byte>(new WebSocketPacket | ||||
|                 { | ||||
|                     Type = "error.dupe", | ||||
|                     ErrorMessage = "Too many connections from the same device and account." | ||||
|                 }.ToBytes()), | ||||
|                 WebSocketMessageType.Binary, | ||||
|                 true, | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|             await webSocket.CloseAsync( | ||||
|                 WebSocketCloseStatus.PolicyViolation, | ||||
|                 "Too many connections from the same device and account.", | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         logger.LogDebug( | ||||
|             $"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, | ||||
|                 "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly", | ||||
|                 currentUser.Name, | ||||
|                 currentUser.Id, | ||||
|                 deviceId | ||||
|             ); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             ws.Disconnect(connectionKey); | ||||
|             logger.LogDebug( | ||||
|                 $"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}" | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task _ConnectionEventLoop( | ||||
|         string deviceId, | ||||
|         Account currentUser, | ||||
|         WebSocket webSocket, | ||||
|         CancellationToken cancellationToken | ||||
|     ) | ||||
|     { | ||||
|         var connectionKey = (AccountId: currentUser.Id, DeviceId: deviceId); | ||||
|  | ||||
|         var buffer = new byte[1024 * 4]; | ||||
|         try | ||||
|         { | ||||
|             while (true) | ||||
|             { | ||||
|                 var receiveResult = await webSocket.ReceiveAsync( | ||||
|                     new ArraySegment<byte>(buffer), | ||||
|                     cancellationToken | ||||
|                 ); | ||||
|  | ||||
|                 if (receiveResult.CloseStatus.HasValue) | ||||
|                     break; | ||||
|  | ||||
|                 var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]); | ||||
|                 await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket); | ||||
|             } | ||||
|         } | ||||
|         catch (OperationCanceledException) | ||||
|         { | ||||
|             if ( | ||||
|                 webSocket.State != WebSocketState.Closed | ||||
|                 && webSocket.State != WebSocketState.Aborted | ||||
|             ) | ||||
|             { | ||||
|                 ws.Disconnect(connectionKey); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										97
									
								
								DysonNetwork.Ring/Connection/WebSocketPacket.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								DysonNetwork.Ring/Connection/WebSocketPacket.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.SystemTextJson; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
|  | ||||
| public class WebSocketPacket | ||||
| { | ||||
|     public string Type { get; set; } = null!; | ||||
|  | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public object? Data { get; set; } = null!; | ||||
|  | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? Endpoint { get; set; } | ||||
|  | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? ErrorMessage { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a WebSocketPacket from raw WebSocket message bytes | ||||
|     /// </summary> | ||||
|     /// <param name="bytes">Raw WebSocket message bytes</param> | ||||
|     /// <returns>Deserialized WebSocketPacket</returns> | ||||
|     public static WebSocketPacket FromBytes(byte[] bytes) | ||||
|     { | ||||
|         var json = System.Text.Encoding.UTF8.GetString(bytes); | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|         }; | ||||
|         return JsonSerializer.Deserialize<WebSocketPacket>(json, jsonOpts) ?? | ||||
|                throw new JsonException("Failed to deserialize WebSocketPacket"); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Deserializes the Data property to the specified type T | ||||
|     /// </summary> | ||||
|     /// <typeparam name="T">Target type to deserialize to</typeparam> | ||||
|     /// <returns>Deserialized data of type T</returns> | ||||
|     public T? GetData<T>() | ||||
|     { | ||||
|         if (Data is T typedData) | ||||
|             return typedData; | ||||
|  | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|         }; | ||||
|         return JsonSerializer.Deserialize<T>( | ||||
|             JsonSerializer.Serialize(Data, jsonOpts), | ||||
|             jsonOpts | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Serializes this WebSocketPacket to a byte array for sending over WebSocket | ||||
|     /// </summary> | ||||
|     /// <returns>Byte array representation of the packet</returns> | ||||
|     public byte[] ToBytes() | ||||
|     { | ||||
|         var jsonOpts = new JsonSerializerOptions | ||||
|         { | ||||
|             NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, | ||||
|             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||
|         }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||
|         var json = JsonSerializer.Serialize(this, jsonOpts); | ||||
|         return System.Text.Encoding.UTF8.GetBytes(json); | ||||
|     } | ||||
|  | ||||
|     public Shared.Proto.WebSocketPacket ToProtoValue() | ||||
|     { | ||||
|         return new Shared.Proto.WebSocketPacket | ||||
|         { | ||||
|             Type = Type, | ||||
|             Data = GrpcTypeHelper.ConvertObjectToByteString(Data), | ||||
|             ErrorMessage = ErrorMessage | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public static WebSocketPacket FromProtoValue(Shared.Proto.WebSocketPacket packet) | ||||
|     { | ||||
|         return new WebSocketPacket | ||||
|         { | ||||
|             Type = packet.Type, | ||||
|             Data = GrpcTypeHelper.ConvertByteStringToObject<object?>(packet.Data), | ||||
|             ErrorMessage = packet.ErrorMessage | ||||
|         }; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										200
									
								
								DysonNetwork.Ring/Connection/WebSocketService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								DysonNetwork.Ring/Connection/WebSocketService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | ||||
| using System.Collections.Concurrent; | ||||
| using System.Net.WebSockets; | ||||
| using dotnet_etcd.interfaces; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Grpc.Core; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Connection; | ||||
|  | ||||
| public class WebSocketService | ||||
| { | ||||
|     private readonly IConfiguration _configuration; | ||||
|     private readonly ILogger<WebSocketService> _logger; | ||||
|     private readonly IEtcdClient _etcdClient; | ||||
|     private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap; | ||||
|  | ||||
|     public WebSocketService( | ||||
|         IEnumerable<IWebSocketPacketHandler> handlers, | ||||
|         IEtcdClient etcdClient, | ||||
|         ILogger<WebSocketService> logger, | ||||
|         IConfiguration configuration | ||||
|     ) | ||||
|     { | ||||
|         _etcdClient = etcdClient; | ||||
|         _logger = logger; | ||||
|         _configuration = configuration; | ||||
|         _handlerMap = handlers.ToDictionary(h => h.PacketType); | ||||
|     } | ||||
|  | ||||
|     private static readonly ConcurrentDictionary< | ||||
|         (string AccountId, string DeviceId), | ||||
|         (WebSocket Socket, CancellationTokenSource Cts) | ||||
|     > ActiveConnections = new(); | ||||
|  | ||||
|     private static readonly ConcurrentDictionary<string, string> ActiveSubscriptions = new(); // deviceId -> chatRoomId | ||||
|  | ||||
|     public bool TryAdd( | ||||
|         (string AccountId, string DeviceId) key, | ||||
|         WebSocket socket, | ||||
|         CancellationTokenSource cts | ||||
|     ) | ||||
|     { | ||||
|         if (ActiveConnections.TryGetValue(key, out _)) | ||||
|             Disconnect(key, | ||||
|                 "Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier | ||||
|         return ActiveConnections.TryAdd(key, (socket, cts)); | ||||
|     } | ||||
|  | ||||
|     public void Disconnect((string AccountId, string DeviceId) key, string? reason = null) | ||||
|     { | ||||
|         if (!ActiveConnections.TryGetValue(key, out var data)) return; | ||||
|         try | ||||
|         { | ||||
|             data.Socket.CloseAsync( | ||||
|                 WebSocketCloseStatus.NormalClosure, | ||||
|                 reason ?? "Server just decided to disconnect.", | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, key.DeviceId); | ||||
|         } | ||||
|         data.Cts.Cancel(); | ||||
|         ActiveConnections.TryRemove(key, out _); | ||||
|     } | ||||
|  | ||||
|     public bool GetDeviceIsConnected(string deviceId) | ||||
|     { | ||||
|         return ActiveConnections.Any(c => c.Key.DeviceId == deviceId); | ||||
|     } | ||||
|  | ||||
|     public bool GetAccountIsConnected(string accountId) | ||||
|     { | ||||
|         return ActiveConnections.Any(c => c.Key.AccountId == accountId); | ||||
|     } | ||||
|  | ||||
|     public void SendPacketToAccount(string userId, WebSocketPacket packet) | ||||
|     { | ||||
|         var connections = ActiveConnections.Where(c => c.Key.AccountId == userId); | ||||
|         var packetBytes = packet.ToBytes(); | ||||
|         var segment = new ArraySegment<byte>(packetBytes); | ||||
|  | ||||
|         foreach (var connection in connections) | ||||
|         { | ||||
|             connection.Value.Socket.SendAsync( | ||||
|                 segment, | ||||
|                 WebSocketMessageType.Binary, | ||||
|                 true, | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void SendPacketToDevice(string deviceId, WebSocketPacket packet) | ||||
|     { | ||||
|         var connections = ActiveConnections.Where(c => c.Key.DeviceId == deviceId); | ||||
|         var packetBytes = packet.ToBytes(); | ||||
|         var segment = new ArraySegment<byte>(packetBytes); | ||||
|  | ||||
|         foreach (var connection in connections) | ||||
|         { | ||||
|             connection.Value.Socket.SendAsync( | ||||
|                 segment, | ||||
|                 WebSocketMessageType.Binary, | ||||
|                 true, | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task HandlePacket( | ||||
|         Account currentUser, | ||||
|         string deviceId, | ||||
|         WebSocketPacket packet, | ||||
|         WebSocket socket | ||||
|     ) | ||||
|     { | ||||
|         if (packet.Type == WebSocketPacketType.Ping) | ||||
|         { | ||||
|             await socket.SendAsync( | ||||
|                 new ArraySegment<byte>(new WebSocketPacket | ||||
|                 { | ||||
|                     Type = WebSocketPacketType.Pong | ||||
|                 }.ToBytes()), | ||||
|                 WebSocketMessageType.Binary, | ||||
|                 true, | ||||
|                 CancellationToken.None | ||||
|             ); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (_handlerMap.TryGetValue(packet.Type, out var handler)) | ||||
|         { | ||||
|             await handler.HandleAsync(currentUser, deviceId, packet, socket, this); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (packet.Endpoint is not null) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 // Get the service URL from etcd for the specified endpoint | ||||
|                 var serviceKey = $"/services/{packet.Endpoint}"; | ||||
|                 var response = await _etcdClient.GetAsync(serviceKey); | ||||
|  | ||||
|                 if (response.Kvs.Count > 0) | ||||
|                 { | ||||
|                     var serviceUrl = response.Kvs[0].Value.ToStringUtf8(); | ||||
|  | ||||
|                     var clientCertPath = _configuration["Service:ClientCert"]!; | ||||
|                     var clientKeyPath = _configuration["Service:ClientKey"]!; | ||||
|                     var clientCertPassword = _configuration["Service:CertPassword"]; | ||||
|  | ||||
|                     var callInvoker = | ||||
|                         GrpcClientHelper.CreateCallInvoker( | ||||
|                             serviceUrl, | ||||
|                             clientCertPath, | ||||
|                             clientKeyPath, | ||||
|                             clientCertPassword | ||||
|                         ); | ||||
|                     var client = new RingHandlerService.RingHandlerServiceClient(callInvoker); | ||||
|  | ||||
|                     try | ||||
|                     { | ||||
|                         await client.ReceiveWebSocketPacketAsync(new ReceiveWebSocketPacketRequest | ||||
|                         { | ||||
|                             Account = currentUser, | ||||
|                             DeviceId = deviceId, | ||||
|                             Packet = packet.ToProtoValue() | ||||
|                         }); | ||||
|                     } | ||||
|                     catch (RpcException ex) | ||||
|                     { | ||||
|                         _logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}"); | ||||
|                     } | ||||
|  | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 _logger.LogWarning($"No service registered for endpoint: {packet.Endpoint}"); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, $"Error forwarding packet to endpoint: {packet.Endpoint}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         await socket.SendAsync( | ||||
|             new ArraySegment<byte>(new WebSocketPacket | ||||
|             { | ||||
|                 Type = WebSocketPacketType.Error, | ||||
|                 ErrorMessage = $"Unprocessable packet: {packet.Type}" | ||||
|             }.ToBytes()), | ||||
|             WebSocketMessageType.Binary, | ||||
|             true, | ||||
|             CancellationToken.None | ||||
|         ); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										27
									
								
								DysonNetwork.Ring/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Ring/Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base | ||||
| WORKDIR /app | ||||
| EXPOSE 8080 | ||||
| EXPOSE 8081 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y --no-install-recommends \ | ||||
|     libkrb5-dev | ||||
|  | ||||
| USER $APP_UID | ||||
|  | ||||
| FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| WORKDIR /src | ||||
| COPY ["DysonNetwork.Ring/DysonNetwork.Ring.csproj", "DysonNetwork.Ring/"] | ||||
| RUN dotnet restore "DysonNetwork.Ring/DysonNetwork.Ring.csproj" | ||||
| COPY . . | ||||
| WORKDIR "/src/DysonNetwork.Ring" | ||||
| RUN dotnet build "./DysonNetwork.Ring.csproj" -c $BUILD_CONFIGURATION -o /app/build | ||||
|  | ||||
| FROM build AS publish | ||||
| ARG BUILD_CONFIGURATION=Release | ||||
| RUN dotnet publish "./DysonNetwork.Ring.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false | ||||
|  | ||||
| FROM base AS final | ||||
| WORKDIR /app | ||||
| COPY --from=publish /app/publish . | ||||
| ENTRYPOINT ["dotnet", "DysonNetwork.Ring.dll"] | ||||
							
								
								
									
										48
									
								
								DysonNetwork.Ring/DysonNetwork.Ring.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								DysonNetwork.Ring/DysonNetwork.Ring.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|  | ||||
|     <PropertyGroup> | ||||
|         <TargetFramework>net9.0</TargetFramework> | ||||
|         <Nullable>enable</Nullable> | ||||
|         <ImplicitUsings>enable</ImplicitUsings> | ||||
|         <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> | ||||
|         <RootNamespace>DysonNetwork.Pusher</RootNamespace> | ||||
|     </PropertyGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="CorePush" Version="4.3.0" /> | ||||
|         <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> | ||||
|         <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> | ||||
|         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> | ||||
|         <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" /> | ||||
|         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> | ||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|           <PrivateAssets>all</PrivateAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> | ||||
|           <PrivateAssets>all</PrivateAssets> | ||||
|           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="NodaTime" Version="3.2.2" /> | ||||
|         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" /> | ||||
|         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> | ||||
|         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||
|         <PackageReference Include="Quartz" Version="3.14.0" /> | ||||
|         <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> | ||||
|         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" /> | ||||
|         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|       <Content Include="..\.dockerignore"> | ||||
|         <Link>.dockerignore</Link> | ||||
|       </Content> | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
							
								
								
									
										78
									
								
								DysonNetwork.Ring/Email/EmailService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								DysonNetwork.Ring/Email/EmailService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| using MailKit.Net.Smtp; | ||||
| using MimeKit; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Email; | ||||
|  | ||||
| public class EmailServiceConfiguration | ||||
| { | ||||
|     public string Server { get; set; } = null!; | ||||
|     public int Port { get; set; } | ||||
|     public bool UseSsl { get; set; } | ||||
|     public string Username { get; set; } = null!; | ||||
|     public string Password { get; set; } = null!; | ||||
|     public string FromAddress { get; set; } = null!; | ||||
|     public string FromName { get; set; } = null!; | ||||
|     public string SubjectPrefix { get; set; } = null!; | ||||
| } | ||||
|  | ||||
| public class EmailService | ||||
| { | ||||
|     private readonly EmailServiceConfiguration _configuration; | ||||
|     private readonly ILogger<EmailService> _logger; | ||||
|  | ||||
|     public EmailService(IConfiguration configuration, ILogger<EmailService> logger) | ||||
|     { | ||||
|         var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>(); | ||||
|         _configuration = cfg ?? throw new ArgumentException("Email service was not configured."); | ||||
|         _logger = logger; | ||||
|     } | ||||
|  | ||||
|     public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string htmlBody) | ||||
|     { | ||||
|         subject = $"[{_configuration.SubjectPrefix}] {subject}"; | ||||
|          | ||||
|         _logger.LogInformation($"Sending email to {recipientEmail} with subject {subject}"); | ||||
|  | ||||
|         var emailMessage = new MimeMessage(); | ||||
|         emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress)); | ||||
|         emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail)); | ||||
|         emailMessage.Subject = subject; | ||||
|  | ||||
|         var bodyBuilder = new BodyBuilder { HtmlBody = htmlBody }; | ||||
|  | ||||
|         emailMessage.Body = bodyBuilder.ToMessageBody(); | ||||
|  | ||||
|         using var client = new SmtpClient(); | ||||
|         await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl); | ||||
|         await client.AuthenticateAsync(_configuration.Username, _configuration.Password); | ||||
|         await client.SendAsync(emailMessage); | ||||
|         await client.DisconnectAsync(true); | ||||
|          | ||||
|         _logger.LogInformation($"Email {subject} sent for {recipientEmail}"); | ||||
|     } | ||||
|  | ||||
|     private static string _ConvertHtmlToPlainText(string html) | ||||
|     { | ||||
|         // Remove style tags and their contents | ||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, "<style[^>]*>.*?</style>", "",  | ||||
|             System.Text.RegularExpressions.RegexOptions.Singleline); | ||||
|      | ||||
|         // Replace header tags with text + newlines | ||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n", | ||||
|             System.Text.RegularExpressions.RegexOptions.IgnoreCase); | ||||
|      | ||||
|         // Replace line breaks | ||||
|         html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n"); | ||||
|      | ||||
|         // Remove all remaining HTML tags | ||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", ""); | ||||
|      | ||||
|         // Decode HTML entities | ||||
|         html = System.Net.WebUtility.HtmlDecode(html); | ||||
|      | ||||
|         // Remove excess whitespace | ||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim(); | ||||
|      | ||||
|         return html; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										151
									
								
								DysonNetwork.Ring/Migrations/20250713122638_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								DysonNetwork.Ring/Migrations/20250713122638_InitialMigration.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using DysonNetwork.Ring; | ||||
| 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.Ring.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     [Migration("20250713122638_InitialMigration")] | ||||
|     partial class InitialMigration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Ring.Notification.Notification", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<string>("Content") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("content"); | ||||
|  | ||||
|                     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<Dictionary<string, object>>("Meta") | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("meta"); | ||||
|  | ||||
|                     b.Property<int>("Priority") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("priority"); | ||||
|  | ||||
|                     b.Property<string>("Subtitle") | ||||
|                         .HasMaxLength(2048) | ||||
|                         .HasColumnType("character varying(2048)") | ||||
|                         .HasColumnName("subtitle"); | ||||
|  | ||||
|                     b.Property<string>("Title") | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("title"); | ||||
|  | ||||
|                     b.Property<string>("Topic") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("topic"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("ViewedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("viewed_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_notifications"); | ||||
|  | ||||
|                     b.ToTable("notifications", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Ring.Notification.PushSubscription", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<int>("CountDelivered") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("count_delivered"); | ||||
|  | ||||
|                     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>("DeviceId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("device_id"); | ||||
|  | ||||
|                     b.Property<string>("DeviceToken") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("device_token"); | ||||
|  | ||||
|                     b.Property<Instant?>("LastUsedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_used_at"); | ||||
|  | ||||
|                     b.Property<int>("Provider") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("provider"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_push_subscriptions"); | ||||
|  | ||||
|                     b.HasIndex("AccountId", "DeviceId", "DeletedAt") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_push_subscriptions_account_id_device_id_deleted_at"); | ||||
|  | ||||
|                     b.ToTable("push_subscriptions", (string)null); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,75 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Ring.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class InitialMigration : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "notifications", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     topic = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false), | ||||
|                     title = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true), | ||||
|                     subtitle = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true), | ||||
|                     content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true), | ||||
|                     meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true), | ||||
|                     priority = table.Column<int>(type: "integer", nullable: false), | ||||
|                     viewed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), | ||||
|                     account_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_notifications", x => x.id); | ||||
|                 }); | ||||
|  | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "push_subscriptions", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     account_id = table.Column<Guid>(type: "uuid", nullable: false), | ||||
|                     device_id = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false), | ||||
|                     device_token = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false), | ||||
|                     provider = table.Column<int>(type: "integer", nullable: false), | ||||
|                     count_delivered = table.Column<int>(type: "integer", nullable: false), | ||||
|                     last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), | ||||
|                     created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), | ||||
|                     deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true) | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_push_subscriptions", x => x.id); | ||||
|                 }); | ||||
|  | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_push_subscriptions_account_id_device_id_deleted_at", | ||||
|                 table: "push_subscriptions", | ||||
|                 columns: new[] { "account_id", "device_id", "deleted_at" }, | ||||
|                 unique: true); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropTable( | ||||
|                 name: "notifications"); | ||||
|  | ||||
|             migrationBuilder.DropTable( | ||||
|                 name: "push_subscriptions"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										152
									
								
								DysonNetwork.Ring/Migrations/20250724070546_UpdateNotificationMeta.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								DysonNetwork.Ring/Migrations/20250724070546_UpdateNotificationMeta.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using DysonNetwork.Ring; | ||||
| 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.Ring.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     [Migration("20250724070546_UpdateNotificationMeta")] | ||||
|     partial class UpdateNotificationMeta | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void BuildTargetModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Ring.Notification.Notification", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<string>("Content") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("content"); | ||||
|  | ||||
|                     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<Dictionary<string, object>>("Meta") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("meta"); | ||||
|  | ||||
|                     b.Property<int>("Priority") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("priority"); | ||||
|  | ||||
|                     b.Property<string>("Subtitle") | ||||
|                         .HasMaxLength(2048) | ||||
|                         .HasColumnType("character varying(2048)") | ||||
|                         .HasColumnName("subtitle"); | ||||
|  | ||||
|                     b.Property<string>("Title") | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("title"); | ||||
|  | ||||
|                     b.Property<string>("Topic") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("topic"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("ViewedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("viewed_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_notifications"); | ||||
|  | ||||
|                     b.ToTable("notifications", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Ring.Notification.PushSubscription", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<int>("CountDelivered") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("count_delivered"); | ||||
|  | ||||
|                     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>("DeviceId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("device_id"); | ||||
|  | ||||
|                     b.Property<string>("DeviceToken") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("device_token"); | ||||
|  | ||||
|                     b.Property<Instant?>("LastUsedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_used_at"); | ||||
|  | ||||
|                     b.Property<int>("Provider") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("provider"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_push_subscriptions"); | ||||
|  | ||||
|                     b.HasIndex("AccountId", "DeviceId", "DeletedAt") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_push_subscriptions_account_id_device_id_deleted_at"); | ||||
|  | ||||
|                     b.ToTable("push_subscriptions", (string)null); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| using System.Collections.Generic; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Ring.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class UpdateNotificationMeta : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AlterColumn<Dictionary<string, object>>( | ||||
|                 name: "meta", | ||||
|                 table: "notifications", | ||||
|                 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: "meta", | ||||
|                 table: "notifications", | ||||
|                 type: "jsonb", | ||||
|                 nullable: true, | ||||
|                 oldClrType: typeof(Dictionary<string, object>), | ||||
|                 oldType: "jsonb"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										149
									
								
								DysonNetwork.Ring/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								DysonNetwork.Ring/Migrations/AppDatabaseModelSnapshot.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| // <auto-generated /> | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using DysonNetwork.Ring; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||||
| using NodaTime; | ||||
| using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Ring.Migrations | ||||
| { | ||||
|     [DbContext(typeof(AppDatabase))] | ||||
|     partial class AppDatabaseModelSnapshot : ModelSnapshot | ||||
|     { | ||||
|         protected override void BuildModel(ModelBuilder modelBuilder) | ||||
|         { | ||||
| #pragma warning disable 612, 618 | ||||
|             modelBuilder | ||||
|                 .HasAnnotation("ProductVersion", "9.0.7") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
|  | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Ring.Notification.Notification", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<string>("Content") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("content"); | ||||
|  | ||||
|                     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<Dictionary<string, object>>("Meta") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("jsonb") | ||||
|                         .HasColumnName("meta"); | ||||
|  | ||||
|                     b.Property<int>("Priority") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("priority"); | ||||
|  | ||||
|                     b.Property<string>("Subtitle") | ||||
|                         .HasMaxLength(2048) | ||||
|                         .HasColumnType("character varying(2048)") | ||||
|                         .HasColumnName("subtitle"); | ||||
|  | ||||
|                     b.Property<string>("Title") | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("title"); | ||||
|  | ||||
|                     b.Property<string>("Topic") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(1024) | ||||
|                         .HasColumnType("character varying(1024)") | ||||
|                         .HasColumnName("topic"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.Property<Instant?>("ViewedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("viewed_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_notifications"); | ||||
|  | ||||
|                     b.ToTable("notifications", (string)null); | ||||
|                 }); | ||||
|  | ||||
|             modelBuilder.Entity("DysonNetwork.Ring.Notification.PushSubscription", b => | ||||
|                 { | ||||
|                     b.Property<Guid>("Id") | ||||
|                         .ValueGeneratedOnAdd() | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("id"); | ||||
|  | ||||
|                     b.Property<Guid>("AccountId") | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("account_id"); | ||||
|  | ||||
|                     b.Property<int>("CountDelivered") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("count_delivered"); | ||||
|  | ||||
|                     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>("DeviceId") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("device_id"); | ||||
|  | ||||
|                     b.Property<string>("DeviceToken") | ||||
|                         .IsRequired() | ||||
|                         .HasMaxLength(8192) | ||||
|                         .HasColumnType("character varying(8192)") | ||||
|                         .HasColumnName("device_token"); | ||||
|  | ||||
|                     b.Property<Instant?>("LastUsedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("last_used_at"); | ||||
|  | ||||
|                     b.Property<int>("Provider") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("provider"); | ||||
|  | ||||
|                     b.Property<Instant>("UpdatedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("updated_at"); | ||||
|  | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_push_subscriptions"); | ||||
|  | ||||
|                     b.HasIndex("AccountId", "DeviceId", "DeletedAt") | ||||
|                         .IsUnique() | ||||
|                         .HasDatabaseName("ix_push_subscriptions_account_id_device_id_deleted_at"); | ||||
|  | ||||
|                     b.ToTable("push_subscriptions", (string)null); | ||||
|                 }); | ||||
| #pragma warning restore 612, 618 | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										21
									
								
								DysonNetwork.Ring/Notification/Notification.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								DysonNetwork.Ring/Notification/Notification.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| 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; } = new(); | ||||
|     public int Priority { get; set; } = 10; | ||||
|     public Instant? ViewedAt { get; set; } | ||||
|  | ||||
|     public Guid AccountId { get; set; } | ||||
| } | ||||
|  | ||||
							
								
								
									
										163
									
								
								DysonNetwork.Ring/Notification/NotificationController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								DysonNetwork.Ring/Notification/NotificationController.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/api/notifications")] | ||||
| public class NotificationController( | ||||
|     AppDatabase db, | ||||
|     PushService 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 accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         var count = await db.Notifications | ||||
|             .Where(s => s.AccountId == accountId && 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 = 8 | ||||
|     ) | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         if (currentUserValue is not Account currentUser) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         var totalCount = await db.Notifications | ||||
|             .Where(s => s.AccountId == accountId) | ||||
|             .CountAsync(); | ||||
|         var notifications = await db.Notifications | ||||
|             .Where(s => s.AccountId == accountId) | ||||
|             .OrderByDescending(e => e.CreatedAt) | ||||
|             .Skip(offset) | ||||
|             .Take(take) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         Response.Headers["X-Total"] = totalCount.ToString(); | ||||
|         await nty.MarkNotificationsViewed(notifications.ToList()); | ||||
|  | ||||
|         return Ok(notifications); | ||||
|     } | ||||
|  | ||||
|     [HttpPost("all/read")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult> MarkAllNotificationsViewed() | ||||
|     { | ||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||
|         if (currentUserValue is not Account currentUser) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         await nty.MarkAllNotificationsViewed(accountId); | ||||
|         return Ok(); | ||||
|     } | ||||
|  | ||||
|     public class PushNotificationSubscribeRequest | ||||
|     { | ||||
|         [MaxLength(4096)] public string DeviceToken { get; set; } = null!; | ||||
|         public PushProvider Provider { get; set; } | ||||
|     } | ||||
|  | ||||
|     [HttpPut("subscription")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<PushSubscription>> | ||||
|         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 AuthSession; | ||||
|         if (currentSession == null) return Unauthorized(); | ||||
|  | ||||
|         var result = | ||||
|             await nty.SubscribeDevice( | ||||
|                 currentSession.Challenge.DeviceId, | ||||
|                 request.DeviceToken, | ||||
|                 request.Provider, | ||||
|                 currentUser | ||||
|             ); | ||||
|  | ||||
|         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 AuthSession; | ||||
|         if (currentSession == null) return Unauthorized(); | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|  | ||||
|         var affectedRows = await db.PushSubscriptions | ||||
|             .Where(s => | ||||
|                 s.AccountId == accountId && | ||||
|                 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; | ||||
|     } | ||||
|  | ||||
|     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 | ||||
|     ) | ||||
|     { | ||||
|         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 ?? [], | ||||
|             }, | ||||
|             request.AccountId, | ||||
|             save | ||||
|         ); | ||||
|         return Ok(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										27
									
								
								DysonNetwork.Ring/Notification/NotificationFlushHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Ring/Notification/NotificationFlushHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using EFCore.BulkExtensions; | ||||
| using NodaTime; | ||||
| using Quartz; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public class NotificationFlushHandler(AppDatabase db) : IFlushHandler<Notification> | ||||
| { | ||||
|     public async Task FlushAsync(IReadOnlyList<Notification> items) | ||||
|     { | ||||
|         await db.BulkInsertAsync(items.Select(x => | ||||
|         { | ||||
|             x.CreatedAt = SystemClock.Instance.GetCurrentInstant(); | ||||
|             x.UpdatedAt = x.CreatedAt; | ||||
|             return x; | ||||
|         }), config => config.ConflictOption = ConflictOption.Ignore); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class NotificationFlushJob(FlushBufferService fbs, NotificationFlushHandler hdl) : IJob | ||||
| { | ||||
|     public async Task Execute(IJobExecutionContext context) | ||||
|     { | ||||
|         await fbs.FlushAsync(hdl); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										385
									
								
								DysonNetwork.Ring/Notification/PushService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										385
									
								
								DysonNetwork.Ring/Notification/PushService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,385 @@ | ||||
| using CorePush.Apple; | ||||
| using CorePush.Firebase; | ||||
| using DysonNetwork.Ring.Connection; | ||||
| using DysonNetwork.Ring.Services; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using WebSocketPacket = DysonNetwork.Ring.Connection.WebSocketPacket; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public class PushService | ||||
| { | ||||
|     private readonly AppDatabase _db; | ||||
|     private readonly WebSocketService _ws; | ||||
|     private readonly QueueService _queueService; | ||||
|     private readonly ILogger<PushService> _logger; | ||||
|     private readonly FirebaseSender? _fcm; | ||||
|     private readonly ApnSender? _apns; | ||||
|     private readonly string? _apnsTopic; | ||||
|  | ||||
|     public PushService( | ||||
|         IConfiguration config, | ||||
|         AppDatabase db, | ||||
|         WebSocketService ws, | ||||
|         QueueService queueService, | ||||
|         IHttpClientFactory httpFactory, | ||||
|         ILogger<PushService> logger | ||||
|     ) | ||||
|     { | ||||
|         var cfgSection = config.GetSection("Notifications:Push"); | ||||
|  | ||||
|         // Set up Firebase Cloud Messaging | ||||
|         var fcmConfig = cfgSection.GetValue<string>("Google"); | ||||
|         if (fcmConfig != null && File.Exists(fcmConfig)) | ||||
|             _fcm = new FirebaseSender(File.ReadAllText(fcmConfig), httpFactory.CreateClient()); | ||||
|  | ||||
|         // Set up Apple Push Notification Service | ||||
|         var apnsKeyPath = cfgSection.GetValue<string>("Apple:PrivateKey"); | ||||
|         if (apnsKeyPath != null && File.Exists(apnsKeyPath)) | ||||
|         { | ||||
|             _apns = new ApnSender(new ApnSettings | ||||
|             { | ||||
|                 P8PrivateKey = File.ReadAllText(apnsKeyPath), | ||||
|                 P8PrivateKeyId = cfgSection.GetValue<string>("Apple:PrivateKeyId"), | ||||
|                 TeamId = cfgSection.GetValue<string>("Apple:TeamId"), | ||||
|                 AppBundleIdentifier = cfgSection.GetValue<string>("Apple:BundleIdentifier"), | ||||
|                 ServerType = cfgSection.GetValue<bool>("Production") | ||||
|                     ? ApnServerType.Production | ||||
|                     : ApnServerType.Development | ||||
|             }, httpFactory.CreateClient()); | ||||
|             _apnsTopic = cfgSection.GetValue<string>("Apple:BundleIdentifier"); | ||||
|         } | ||||
|  | ||||
|         _db = db; | ||||
|         _ws = ws; | ||||
|         _queueService = queueService; | ||||
|         _logger = logger; | ||||
|     } | ||||
|  | ||||
|     public async Task UnsubscribeDevice(string deviceId) | ||||
|     { | ||||
|         await _db.PushSubscriptions | ||||
|             .Where(s => s.DeviceId == deviceId) | ||||
|             .ExecuteDeleteAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task<PushSubscription> SubscribeDevice( | ||||
|         string deviceId, | ||||
|         string deviceToken, | ||||
|         PushProvider provider, | ||||
|         Account account | ||||
|     ) | ||||
|     { | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var accountId = Guid.Parse(account.Id!); | ||||
|  | ||||
|         // Check for existing subscription with same device ID or token | ||||
|         var existingSubscription = await _db.PushSubscriptions | ||||
|             .Where(s => s.AccountId == accountId) | ||||
|             .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) | ||||
|             .FirstOrDefaultAsync(); | ||||
|  | ||||
|         if (existingSubscription != null) | ||||
|         { | ||||
|             existingSubscription.DeviceId = deviceId; | ||||
|             existingSubscription.DeviceToken = deviceToken; | ||||
|             existingSubscription.Provider = provider; | ||||
|             existingSubscription.UpdatedAt = now; | ||||
|  | ||||
|             _db.Update(existingSubscription); | ||||
|             await _db.SaveChangesAsync(); | ||||
|             return existingSubscription; | ||||
|         } | ||||
|  | ||||
|         var subscription = new PushSubscription | ||||
|         { | ||||
|             DeviceId = deviceId, | ||||
|             DeviceToken = deviceToken, | ||||
|             Provider = provider, | ||||
|             AccountId = accountId, | ||||
|             CreatedAt = now, | ||||
|             UpdatedAt = now | ||||
|         }; | ||||
|  | ||||
|         _db.PushSubscriptions.Add(subscription); | ||||
|         await _db.SaveChangesAsync(); | ||||
|  | ||||
|         return subscription; | ||||
|     } | ||||
|  | ||||
|     public async Task 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) | ||||
|     { | ||||
|         meta ??= []; | ||||
|         if (title is null && subtitle is null && content is null) | ||||
|             throw new ArgumentException("Unable to send notification that is completely empty."); | ||||
|  | ||||
|         if (actionUri is not null) meta["action_uri"] = actionUri; | ||||
|  | ||||
|         var accountId = Guid.Parse(account.Id!); | ||||
|         var notification = new Notification | ||||
|         { | ||||
|             Topic = topic, | ||||
|             Title = title, | ||||
|             Subtitle = subtitle, | ||||
|             Content = content, | ||||
|             Meta = meta, | ||||
|             AccountId = accountId, | ||||
|         }; | ||||
|  | ||||
|         if (save) | ||||
|         { | ||||
|             _db.Notifications.Add(notification); | ||||
|             await _db.SaveChangesAsync(); | ||||
|         } | ||||
|  | ||||
|         if (!isSilent) | ||||
|             _ = _queueService.EnqueuePushNotification(notification, accountId, save); | ||||
|     } | ||||
|  | ||||
|     public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         _ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket() | ||||
|         { | ||||
|             Type = "notifications.new", | ||||
|             Data = notification, | ||||
|         }); | ||||
|          | ||||
|         try | ||||
|         { | ||||
|             _logger.LogInformation( | ||||
|                 "Delivering push notification: {NotificationTopic} with meta {NotificationMeta}", | ||||
|                 notification.Topic, | ||||
|                 notification.Meta | ||||
|             ); | ||||
|  | ||||
|             // Get all push subscriptions for the account | ||||
|             var subscriptions = await _db.PushSubscriptions | ||||
|                 .Where(s => s.AccountId == notification.AccountId) | ||||
|                 .ToListAsync(cancellationToken); | ||||
|  | ||||
|             if (subscriptions.Count == 0) | ||||
|             { | ||||
|                 _logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Send push notifications | ||||
|             var tasks = new List<Task>(); | ||||
|             foreach (var subscription in subscriptions) | ||||
|             { | ||||
|                 try | ||||
|                 { | ||||
|                     tasks.Add(SendPushNotificationAsync(subscription, notification)); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     _logger.LogError(ex, "Error sending push notification to {DeviceId}", subscription.DeviceId); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             await Task.WhenAll(tasks); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, "Error in DeliverPushNotification"); | ||||
|             throw; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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 MarkAllNotificationsViewed(Guid accountId) | ||||
|     { | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         await _db.Notifications | ||||
|             .Where(n => n.AccountId == accountId) | ||||
|             .Where(n => n.ViewedAt == null) | ||||
|             .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)); | ||||
|     } | ||||
|  | ||||
|     public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false) | ||||
|     { | ||||
|         if (save) | ||||
|         { | ||||
|             var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|             var notifications = accounts.Select(accountId => new Notification | ||||
|             { | ||||
|                 Topic = notification.Topic, | ||||
|                 Title = notification.Title, | ||||
|                 Subtitle = notification.Subtitle, | ||||
|                 Content = notification.Content, | ||||
|                 Meta = notification.Meta, | ||||
|                 Priority = notification.Priority, | ||||
|                 AccountId = accountId, | ||||
|                 CreatedAt = now, | ||||
|                 UpdatedAt = now | ||||
|             }).ToList(); | ||||
|  | ||||
|             if (notifications.Count != 0) | ||||
|             { | ||||
|                 await _db.Notifications.AddRangeAsync(notifications); | ||||
|                 await _db.SaveChangesAsync(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             "Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}", | ||||
|             notification.Topic, | ||||
|             notification.Id, | ||||
|             notification.Meta | ||||
|         ); | ||||
|  | ||||
|         // WS first | ||||
|         foreach (var account in accounts) | ||||
|         { | ||||
|             notification.AccountId = account; // keep original behavior | ||||
|             _ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket | ||||
|             { | ||||
|                 Type = "notifications.new", | ||||
|                 Data = notification | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         await DeliverPushNotification(notification); | ||||
|     } | ||||
|  | ||||
|     private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             _logger.LogDebug( | ||||
|                 $"Pushing notification {notification.Topic} #{notification.Id} to device #{subscription.DeviceId}"); | ||||
|  | ||||
|             switch (subscription.Provider) | ||||
|             { | ||||
|                 case PushProvider.Google: | ||||
|                     if (_fcm == null) | ||||
|                         throw new InvalidOperationException("Firebase Cloud Messaging is not initialized."); | ||||
|  | ||||
|                     var body = string.Empty; | ||||
|                     if (!string.IsNullOrEmpty(notification.Subtitle) || !string.IsNullOrEmpty(notification.Content)) | ||||
|                     { | ||||
|                         body = string.Join("\n", | ||||
|                             notification.Subtitle ?? string.Empty, | ||||
|                             notification.Content ?? string.Empty | ||||
|                         ).Trim(); | ||||
|                     } | ||||
|  | ||||
|                     var fcmResult = await _fcm.SendAsync(new Dictionary<string, object> | ||||
|                     { | ||||
|                         ["message"] = new Dictionary<string, object> | ||||
|                         { | ||||
|                             ["token"] = subscription.DeviceToken, | ||||
|                             ["notification"] = new Dictionary<string, object> | ||||
|                             { | ||||
|                                 ["title"] = notification.Title ?? string.Empty, | ||||
|                                 ["body"] = body | ||||
|                             }, | ||||
|                             // You can re-enable data payloads if needed. | ||||
|                             // ["data"] = new Dictionary<string, object> | ||||
|                             // { | ||||
|                             //     ["Id"] = notification.Id, | ||||
|                             //     ["Topic"] = notification.Topic, | ||||
|                             //     ["Meta"] = notification.Meta | ||||
|                             // } | ||||
|                         } | ||||
|                     }); | ||||
|  | ||||
|                     if (fcmResult.Error != null) | ||||
|                         throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}"); | ||||
|                     break; | ||||
|  | ||||
|                 case PushProvider.Apple: | ||||
|                     if (_apns == null) | ||||
|                         throw new InvalidOperationException("Apple Push Notification Service is not initialized."); | ||||
|  | ||||
|                     var alertDict = new Dictionary<string, object>(); | ||||
|                     if (!string.IsNullOrEmpty(notification.Title)) | ||||
|                         alertDict["title"] = notification.Title; | ||||
|                     if (!string.IsNullOrEmpty(notification.Subtitle)) | ||||
|                         alertDict["subtitle"] = notification.Subtitle; | ||||
|                     if (!string.IsNullOrEmpty(notification.Content)) | ||||
|                         alertDict["body"] = notification.Content; | ||||
|  | ||||
|                     var payload = new Dictionary<string, object?> | ||||
|                     { | ||||
|                         ["topic"] = _apnsTopic, | ||||
|                         ["type"] = notification.Topic, | ||||
|                         ["aps"] = new Dictionary<string, object?> | ||||
|                         { | ||||
|                             ["alert"] = alertDict, | ||||
|                             ["sound"] = notification.Priority >= 5 ? "default" : null, | ||||
|                             ["mutable-content"] = 1 | ||||
|                         }, | ||||
|                         ["meta"] = notification.Meta | ||||
|                     }; | ||||
|  | ||||
|                     var apnResult = await _apns.SendAsync( | ||||
|                         payload, | ||||
|                         deviceToken: subscription.DeviceToken, | ||||
|                         apnsId: notification.Id.ToString(), | ||||
|                         apnsPriority: notification.Priority, | ||||
|                         apnPushType: ApnPushType.Alert | ||||
|                     ); | ||||
|                     if (apnResult.Error != null) | ||||
|                         throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}"); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 default: | ||||
|                     throw new InvalidOperationException($"Push provider not supported: {subscription.Provider}"); | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             _logger.LogError(ex, | ||||
|                 $"Failed to push notification #{notification.Id} to device {subscription.DeviceId}. {ex.Message}"); | ||||
|             // Swallow here to keep worker alive; upstream is fire-and-forget. | ||||
|         } | ||||
|  | ||||
|         _logger.LogInformation( | ||||
|             $"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}"); | ||||
|     } | ||||
|  | ||||
|     public async Task SaveNotification(Notification notification) | ||||
|     { | ||||
|         _db.Notifications.Add(notification); | ||||
|         await _db.SaveChangesAsync(); | ||||
|     } | ||||
|  | ||||
|     public async Task SaveNotification(Notification notification, List<Guid> accounts) | ||||
|     { | ||||
|         _db.Notifications.AddRange(accounts.Select(a => new Notification | ||||
|         { | ||||
|             AccountId = a, | ||||
|             Topic = notification.Topic, | ||||
|             Content = notification.Content, | ||||
|             Title = notification.Title, | ||||
|             Subtitle = notification.Subtitle, | ||||
|             Meta = notification.Meta, | ||||
|             Priority = notification.Priority, | ||||
|             CreatedAt = notification.CreatedAt, | ||||
|             UpdatedAt = notification.UpdatedAt, | ||||
|         })); | ||||
|         await _db.SaveChangesAsync(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								DysonNetwork.Ring/Notification/PushSubscription.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								DysonNetwork.Ring/Notification/PushSubscription.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Notification; | ||||
|  | ||||
| public enum PushProvider | ||||
| { | ||||
|     Apple, | ||||
|     Google | ||||
| } | ||||
|  | ||||
| [Index(nameof(AccountId), nameof(DeviceId), nameof(DeletedAt), IsUnique = true)] | ||||
| public class PushSubscription : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     public Guid AccountId { get; set; } | ||||
|     [MaxLength(8192)] public string DeviceId { get; set; } = null!; | ||||
|     [MaxLength(8192)] public string DeviceToken { get; set; } = null!; | ||||
|     public PushProvider Provider { get; set; } | ||||
|      | ||||
|     public int CountDelivered { get; set; } | ||||
|     public Instant? LastUsedAt { get; set; } | ||||
| } | ||||
							
								
								
									
										48
									
								
								DysonNetwork.Ring/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								DysonNetwork.Ring/Program.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| using DysonNetwork.Ring; | ||||
| using DysonNetwork.Ring.Startup; | ||||
| using DysonNetwork.Shared.Auth; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| // Configure Kestrel and server options | ||||
| builder.ConfigureAppKestrel(builder.Configuration); | ||||
|  | ||||
| // Add application services | ||||
| builder.Services.AddRegistryService(builder.Configuration); | ||||
| builder.Services.AddStreamConnection(builder.Configuration); | ||||
| builder.Services.AddAppServices(builder.Configuration); | ||||
| builder.Services.AddAppRateLimiting(); | ||||
| builder.Services.AddAppAuthentication(); | ||||
| builder.Services.AddAppSwagger(); | ||||
| builder.Services.AddDysonAuth(); | ||||
| builder.Services.AddAccountService(); | ||||
|  | ||||
| // Add flush handlers and websocket handlers | ||||
| builder.Services.AddAppFlushHandlers(); | ||||
|  | ||||
| // Add business services | ||||
| builder.Services.AddAppBusinessServices(); | ||||
|  | ||||
| // Add scheduled jobs | ||||
| builder.Services.AddAppScheduledJobs(); | ||||
|  | ||||
| var app = builder.Build(); | ||||
|  | ||||
| // Run database migrations | ||||
| using (var scope = app.Services.CreateScope()) | ||||
| { | ||||
|     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||
|     await db.Database.MigrateAsync(); | ||||
| } | ||||
|  | ||||
| // Configure application middleware pipeline | ||||
| app.ConfigureAppMiddleware(builder.Configuration); | ||||
|  | ||||
| // Configure gRPC | ||||
| app.ConfigureGrpcServices(); | ||||
|  | ||||
| app.Run(); | ||||
							
								
								
									
										23
									
								
								DysonNetwork.Ring/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								DysonNetwork.Ring/Properties/launchSettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|   "$schema": "https://json.schemastore.org/launchsettings.json", | ||||
|   "profiles": { | ||||
|     "http": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": false, | ||||
|       "applicationUrl": "http://localhost:5212", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     }, | ||||
|     "https": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "launchBrowser": false, | ||||
|       "applicationUrl": "https://localhost:7259;http://localhost:5212", | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										172
									
								
								DysonNetwork.Ring/Services/PusherServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								DysonNetwork.Ring/Services/PusherServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| using DysonNetwork.Ring.Connection; | ||||
| using DysonNetwork.Ring.Email; | ||||
| using DysonNetwork.Ring.Notification; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using Google.Protobuf.WellKnownTypes; | ||||
| using Grpc.Core; | ||||
| using System.Text.Json; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Services; | ||||
|  | ||||
| public class RingServiceGrpc( | ||||
|     QueueService queueService, | ||||
|     WebSocketService websocket, | ||||
|     PushService pushService | ||||
| ) : RingService.RingServiceBase | ||||
| { | ||||
|     public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context) | ||||
|     { | ||||
|         await queueService.EnqueueEmail( | ||||
|             request.Email.ToName, | ||||
|             request.Email.ToAddress, | ||||
|             request.Email.Subject, | ||||
|             request.Email.Body | ||||
|         ); | ||||
|         return new Empty(); | ||||
|     } | ||||
|  | ||||
|     public override Task<Empty> PushWebSocketPacket(PushWebSocketPacketRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var packet = new Connection.WebSocketPacket | ||||
|         { | ||||
|             Type = request.Packet.Type, | ||||
|             Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data), | ||||
|             ErrorMessage = request.Packet.ErrorMessage | ||||
|         }; | ||||
|         websocket.SendPacketToAccount(request.UserId, packet); | ||||
|         return Task.FromResult(new Empty()); | ||||
|     } | ||||
|  | ||||
|     public override Task<Empty> PushWebSocketPacketToUsers(PushWebSocketPacketToUsersRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         var packet = new Connection.WebSocketPacket | ||||
|         { | ||||
|             Type = request.Packet.Type, | ||||
|             Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data), | ||||
|             ErrorMessage = request.Packet.ErrorMessage | ||||
|         }; | ||||
|          | ||||
|         foreach (var userId in request.UserIds) | ||||
|         { | ||||
|             websocket.SendPacketToAccount(userId, packet); | ||||
|         } | ||||
|          | ||||
|         return Task.FromResult(new Empty()); | ||||
|     } | ||||
|  | ||||
| public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         var packet = new Connection.WebSocketPacket | ||||
|         { | ||||
|             Type = request.Packet.Type, | ||||
|             Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data), | ||||
|             ErrorMessage = request.Packet.ErrorMessage | ||||
|         }; | ||||
|         websocket.SendPacketToDevice(request.DeviceId, packet); | ||||
|         return Task.FromResult(new Empty()); | ||||
|     } | ||||
|  | ||||
|     public override Task<Empty> PushWebSocketPacketToDevices(PushWebSocketPacketToDevicesRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         var packet = new Connection.WebSocketPacket | ||||
|         { | ||||
|             Type = request.Packet.Type, | ||||
|             Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data), | ||||
|             ErrorMessage = request.Packet.ErrorMessage | ||||
|         }; | ||||
|          | ||||
|         foreach (var deviceId in request.DeviceIds) | ||||
|         { | ||||
|             websocket.SendPacketToDevice(deviceId, packet); | ||||
|         } | ||||
|          | ||||
|         return Task.FromResult(new Empty()); | ||||
|     } | ||||
|  | ||||
|     public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         var notification = new Notification.Notification | ||||
|         { | ||||
|             Topic = request.Notification.Topic, | ||||
|             Title = request.Notification.Title, | ||||
|             Subtitle = request.Notification.Subtitle, | ||||
|             Content = request.Notification.Body, | ||||
|             Meta = request.Notification.HasMeta | ||||
|                 ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? [] | ||||
|                 : [], | ||||
|             AccountId = Guid.Parse(request.UserId), | ||||
|         }; | ||||
|          | ||||
|         if (request.Notification.ActionUri is not null) | ||||
|             notification.Meta["action_uri"] = request.Notification.ActionUri; | ||||
|  | ||||
|         if (request.Notification.IsSavable) | ||||
|             await pushService.SaveNotification(notification); | ||||
|              | ||||
|         await queueService.EnqueuePushNotification( | ||||
|             notification, | ||||
|             Guid.Parse(request.UserId), | ||||
|             request.Notification.IsSavable | ||||
|         ); | ||||
|          | ||||
|         return new Empty(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<Empty> SendPushNotificationToUsers(SendPushNotificationToUsersRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         var notification = new Notification.Notification | ||||
|         { | ||||
|             Topic = request.Notification.Topic, | ||||
|             Title = request.Notification.Title, | ||||
|             Subtitle = request.Notification.Subtitle, | ||||
|             Content = request.Notification.Body, | ||||
|             Meta = request.Notification.HasMeta | ||||
|                 ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? [] | ||||
|                 : [], | ||||
|         }; | ||||
|          | ||||
|         if (request.Notification.ActionUri is not null) | ||||
|             notification.Meta["action_uri"] = request.Notification.ActionUri; | ||||
|  | ||||
|         var userIds = request.UserIds.Select(Guid.Parse).ToList(); | ||||
|         if (request.Notification.IsSavable) | ||||
|             await pushService.SaveNotification(notification, userIds); | ||||
|              | ||||
|         var tasks = userIds | ||||
|             .Select(userId => queueService.EnqueuePushNotification( | ||||
|                 notification, | ||||
|                 userId, | ||||
|                 request.Notification.IsSavable | ||||
|             )); | ||||
|              | ||||
|         await Task.WhenAll(tasks); | ||||
|         return new Empty(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<Empty> UnsubscribePushNotifications(UnsubscribePushNotificationsRequest request, | ||||
|         ServerCallContext context) | ||||
|     { | ||||
|         await pushService.UnsubscribeDevice(request.DeviceId); | ||||
|         return new Empty(); | ||||
|     } | ||||
|  | ||||
|     public override Task<GetWebsocketConnectionStatusResponse> GetWebsocketConnectionStatus( | ||||
|         GetWebsocketConnectionStatusRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var isConnected = request.IdCase switch | ||||
|         { | ||||
|             GetWebsocketConnectionStatusRequest.IdOneofCase.DeviceId => | ||||
|                 websocket.GetDeviceIsConnected(request.DeviceId), | ||||
|             GetWebsocketConnectionStatusRequest.IdOneofCase.UserId => websocket.GetAccountIsConnected(request.UserId), | ||||
|             _ => false | ||||
|         }; | ||||
|  | ||||
|         return Task.FromResult(new GetWebsocketConnectionStatusResponse { IsConnected = isConnected }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										134
									
								
								DysonNetwork.Ring/Services/QueueBackgroundService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								DysonNetwork.Ring/Services/QueueBackgroundService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Ring.Email; | ||||
| using DysonNetwork.Ring.Notification; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Registry; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Google.Protobuf; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream; | ||||
| using NATS.Client.JetStream.Models; | ||||
| using NATS.Net; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Services; | ||||
|  | ||||
| public class QueueBackgroundService( | ||||
|     INatsConnection nats, | ||||
|     IServiceProvider serviceProvider, | ||||
|     ILogger<QueueBackgroundService> logger, | ||||
|     IConfiguration configuration | ||||
| ) | ||||
|     : BackgroundService | ||||
| { | ||||
|     public const string QueueName = "pusher_queue"; | ||||
|     private const string QueueGroup = "pusher_workers"; | ||||
|     private readonly int _consumerCount = configuration.GetValue<int?>("ConsumerCount") ?? Environment.ProcessorCount; | ||||
|     private readonly List<Task> _consumerTasks = []; | ||||
|  | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         logger.LogInformation("Starting {ConsumerCount} queue consumers", _consumerCount); | ||||
|  | ||||
|         // Start multiple consumers | ||||
|         for (var i = 0; i < _consumerCount; i++) | ||||
|             _consumerTasks.Add(Task.Run(() => RunConsumerAsync(stoppingToken), stoppingToken)); | ||||
|  | ||||
|         // Wait for all consumers to complete | ||||
|         await Task.WhenAll(_consumerTasks); | ||||
|     } | ||||
|  | ||||
|     private async Task RunConsumerAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         logger.LogInformation("Queue consumer started"); | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|  | ||||
|         await js.EnsureStreamCreated("pusher_events", [QueueName]); | ||||
|          | ||||
|         var consumer = await js.CreateOrUpdateConsumerAsync( | ||||
|             "pusher_events",  | ||||
|             new ConsumerConfig(QueueGroup), // durable consumer | ||||
|             cancellationToken: stoppingToken); | ||||
|  | ||||
|         await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken)) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data)); | ||||
|                 if (message is not null) | ||||
|                 { | ||||
|                     await ProcessMessageAsync(msg, message, stoppingToken); | ||||
|                     await msg.AckAsync(cancellationToken: stoppingToken); | ||||
|                 } | ||||
|                 else | ||||
|                 { | ||||
|                     logger.LogWarning($"Invalid message format for {msg.Subject}"); | ||||
|                     await msg.AckAsync(cancellationToken: stoppingToken); // Acknowledge invalid messages to avoid redelivery | ||||
|                 } | ||||
|             } | ||||
|             catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) | ||||
|             { | ||||
|                 // Normal shutdown | ||||
|                 break; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 logger.LogError(ex, "Error in queue consumer"); | ||||
|                 await msg.NakAsync(cancellationToken: stoppingToken); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async ValueTask ProcessMessageAsync(NatsJSMsg<byte[]> rawMsg, QueueMessage message, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         using var scope = serviceProvider.CreateScope(); | ||||
|  | ||||
|         logger.LogDebug("Processing message of type {MessageType}", message.Type); | ||||
|  | ||||
|         switch (message.Type) | ||||
|         { | ||||
|             case QueueMessageType.Email: | ||||
|                 await ProcessEmailMessageAsync(message, scope); | ||||
|                 break; | ||||
|  | ||||
|             case QueueMessageType.PushNotification: | ||||
|                 await ProcessPushNotificationMessageAsync(message, scope, cancellationToken); | ||||
|                 break; | ||||
|  | ||||
|             default: | ||||
|                 logger.LogWarning("Unknown message type: {MessageType}", message.Type); | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static async Task ProcessEmailMessageAsync(QueueMessage message, IServiceScope scope) | ||||
|     { | ||||
|         var emailService = scope.ServiceProvider.GetRequiredService<EmailService>(); | ||||
|         var emailMessage = JsonSerializer.Deserialize<EmailMessage>(message.Data) | ||||
|                            ?? throw new InvalidOperationException("Invalid email message format"); | ||||
|  | ||||
|         await emailService.SendEmailAsync( | ||||
|             emailMessage.ToName, | ||||
|             emailMessage.ToAddress, | ||||
|             emailMessage.Subject, | ||||
|             emailMessage.Body); | ||||
|     } | ||||
|  | ||||
|     private static async Task ProcessPushNotificationMessageAsync(QueueMessage message, IServiceScope scope, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var pushService = scope.ServiceProvider.GetRequiredService<PushService>(); | ||||
|         var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>(); | ||||
|          | ||||
|         var notification = JsonSerializer.Deserialize<Notification.Notification>(message.Data); | ||||
|         if (notification == null) | ||||
|         { | ||||
|             logger.LogError("Invalid push notification data format"); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         logger.LogDebug("Processing push notification for account {AccountId}", notification.AccountId); | ||||
|         await pushService.DeliverPushNotification(notification, cancellationToken); | ||||
|         logger.LogDebug("Successfully processed push notification for account {AccountId}", notification.AccountId); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										65
									
								
								DysonNetwork.Ring/Services/QueueService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								DysonNetwork.Ring/Services/QueueService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using NATS.Client.Core; | ||||
| using NATS.Client.JetStream; | ||||
| using NATS.Net; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Services; | ||||
|  | ||||
| public class QueueService(INatsConnection nats) | ||||
| { | ||||
|     public async Task EnqueueEmail(string toName, string toAddress, string subject, string body) | ||||
|     { | ||||
|         var message = new QueueMessage | ||||
|         { | ||||
|             Type = QueueMessageType.Email, | ||||
|             Data = JsonSerializer.Serialize(new EmailMessage | ||||
|             { | ||||
|                 ToName = toName, | ||||
|                 ToAddress = toAddress, | ||||
|                 Subject = subject, | ||||
|                 Body = body | ||||
|             }) | ||||
|         }; | ||||
|         var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray(); | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|         await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage); | ||||
|     } | ||||
|  | ||||
|     public async Task EnqueuePushNotification(Notification.Notification notification, Guid userId, bool isSavable = false) | ||||
|     { | ||||
|         // Update the account ID in case it wasn't set | ||||
|         notification.AccountId = userId; | ||||
|          | ||||
|         var message = new QueueMessage | ||||
|         { | ||||
|             Type = QueueMessageType.PushNotification, | ||||
|             TargetId = userId.ToString(), | ||||
|             Data = JsonSerializer.Serialize(notification) | ||||
|         }; | ||||
|         var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray(); | ||||
|         var js = nats.CreateJetStreamContext(); | ||||
|         await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class QueueMessage | ||||
| { | ||||
|     public QueueMessageType Type { get; set; } | ||||
|     public string? TargetId { get; set; } | ||||
|     public string Data { get; set; } = string.Empty; | ||||
| } | ||||
|  | ||||
| public enum QueueMessageType | ||||
| { | ||||
|     Email, | ||||
|     PushNotification | ||||
| } | ||||
|  | ||||
| public class EmailMessage | ||||
| { | ||||
|     public string ToName { get; set; } = string.Empty; | ||||
|     public string ToAddress { get; set; } = string.Empty; | ||||
|     public string Subject { get; set; } = string.Empty; | ||||
|     public string Body { get; set; } = string.Empty; | ||||
| } | ||||
							
								
								
									
										46
									
								
								DysonNetwork.Ring/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								DysonNetwork.Ring/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| using System.Net; | ||||
| using DysonNetwork.Ring.Services; | ||||
| using DysonNetwork.Shared.Http; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Startup; | ||||
|  | ||||
| public static class ApplicationConfiguration | ||||
| { | ||||
|     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) | ||||
|     { | ||||
|         app.MapOpenApi(); | ||||
|  | ||||
|         app.UseSwagger(); | ||||
|         app.UseSwaggerUI(); | ||||
|          | ||||
|         app.UseRequestLocalization(); | ||||
|  | ||||
|         app.ConfigureForwardedHeaders(configuration); | ||||
|  | ||||
|         app.UseCors(opts => | ||||
|             opts.SetIsOriginAllowed(_ => true) | ||||
|                 .WithExposedHeaders("*") | ||||
|                 .WithHeaders("*") | ||||
|                 .AllowCredentials() | ||||
|                 .AllowAnyHeader() | ||||
|                 .AllowAnyMethod() | ||||
|         ); | ||||
|  | ||||
|         app.UseWebSockets(); | ||||
|         app.UseRateLimiter(); | ||||
|         app.UseAuthentication(); | ||||
|         app.UseAuthorization(); | ||||
|  | ||||
|         app.MapControllers().RequireRateLimiting("fixed"); | ||||
|  | ||||
|         return app; | ||||
|     } | ||||
|      | ||||
|     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||
|     { | ||||
|         app.MapGrpcService<RingServiceGrpc>(); | ||||
|          | ||||
|         return app; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										30
									
								
								DysonNetwork.Ring/Startup/ScheduledJobsConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								DysonNetwork.Ring/Startup/ScheduledJobsConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| using DysonNetwork.Ring.Notification; | ||||
| using Quartz; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Startup; | ||||
|  | ||||
| public static class ScheduledJobsConfiguration | ||||
| { | ||||
|     public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddQuartz(q => | ||||
|         { | ||||
|             var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling"); | ||||
|             q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob)); | ||||
|             q.AddTrigger(opts => opts | ||||
|                 .ForJob(appDatabaseRecyclingJob) | ||||
|                 .WithIdentity("AppDatabaseRecyclingTrigger") | ||||
|                 .WithCronSchedule("0 0 0 * * ?")); | ||||
|  | ||||
|             var notificationFlushJob = new JobKey("NotificationFlush"); | ||||
|             q.AddJob<NotificationFlushJob>(opts => opts.WithIdentity(notificationFlushJob)); | ||||
|             q.AddTrigger(opts => opts | ||||
|                 .ForJob(notificationFlushJob) | ||||
|                 .WithIdentity("NotificationFlushTrigger") | ||||
|                 .WithSimpleSchedule(a => a.WithIntervalInSeconds(60).RepeatForever())); | ||||
|         }); | ||||
|         services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										151
									
								
								DysonNetwork.Ring/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								DysonNetwork.Ring/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading.RateLimiting; | ||||
| using CorePush.Apple; | ||||
| using CorePush.Firebase; | ||||
| using DysonNetwork.Ring.Connection; | ||||
| using DysonNetwork.Ring.Email; | ||||
| using DysonNetwork.Ring.Notification; | ||||
| using DysonNetwork.Ring.Services; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.AspNetCore.RateLimiting; | ||||
| using Microsoft.OpenApi.Models; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.SystemTextJson; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace DysonNetwork.Ring.Startup; | ||||
|  | ||||
| public static class ServiceCollectionExtensions | ||||
| { | ||||
|     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) | ||||
|     { | ||||
|         services.AddDbContext<AppDatabase>(); | ||||
|         services.AddSingleton<IConnectionMultiplexer>(_ => | ||||
|         { | ||||
|             var connection = configuration.GetConnectionString("FastRetrieve")!; | ||||
|             return ConnectionMultiplexer.Connect(connection); | ||||
|         }); | ||||
|         services.AddSingleton<IClock>(SystemClock.Instance); | ||||
|         services.AddHttpContextAccessor(); | ||||
|         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||
|  | ||||
|         services.AddHttpClient(); | ||||
|  | ||||
|         // Register gRPC services | ||||
|         services.AddGrpc(options => | ||||
|         { | ||||
|             options.EnableDetailedErrors = true; // Will be adjusted in Program.cs | ||||
|             options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB | ||||
|             options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB | ||||
|         }); | ||||
|  | ||||
|         // Register gRPC reflection for service discovery | ||||
|         services.AddGrpc(); | ||||
|  | ||||
|         // Register gRPC services | ||||
|         services.AddScoped<RingServiceGrpc>(); | ||||
|  | ||||
|         // Register OIDC services | ||||
|         services.AddControllers().AddJsonOptions(options => | ||||
|         { | ||||
|             options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; | ||||
|             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||
|  | ||||
|             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||
|         }); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IServiceCollection AddAppRateLimiting(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts => | ||||
|         { | ||||
|             opts.Window = TimeSpan.FromMinutes(1); | ||||
|             opts.PermitLimit = 120; | ||||
|             opts.QueueLimit = 2; | ||||
|             opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; | ||||
|         })); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IServiceCollection AddAppAuthentication(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddCors(); | ||||
|         services.AddAuthorization(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IServiceCollection AddAppSwagger(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddEndpointsApiExplorer(); | ||||
|         services.AddSwaggerGen(options => | ||||
|         { | ||||
|             options.SwaggerDoc("v1", new OpenApiInfo | ||||
|             { | ||||
|                 Version = "v1", | ||||
|                 Title = "Dyson Ring", | ||||
|                 Description = "The pusher service of the Dyson Network. Mainly handling emailing, notifications and websockets.", | ||||
|                 TermsOfService = new Uri("https://solsynth.dev/terms"), | ||||
|                 License = new OpenApiLicense | ||||
|                 { | ||||
|                     Name = "APGLv3", | ||||
|                     Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html") | ||||
|                 } | ||||
|             }); | ||||
|             options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme | ||||
|             { | ||||
|                 In = ParameterLocation.Header, | ||||
|                 Description = "Please enter a valid token", | ||||
|                 Name = "Authorization", | ||||
|                 Type = SecuritySchemeType.Http, | ||||
|                 BearerFormat = "JWT", | ||||
|                 Scheme = "Bearer" | ||||
|             }); | ||||
|             options.AddSecurityRequirement(new OpenApiSecurityRequirement | ||||
|             { | ||||
|                 { | ||||
|                     new OpenApiSecurityScheme | ||||
|                     { | ||||
|                         Reference = new OpenApiReference | ||||
|                         { | ||||
|                             Type = ReferenceType.SecurityScheme, | ||||
|                             Id = "Bearer" | ||||
|                         } | ||||
|                     }, | ||||
|                     [] | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|         services.AddOpenApi(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddSingleton<FlushBufferService>(); | ||||
|         services.AddScoped<NotificationFlushHandler>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|  | ||||
|     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) | ||||
|     { | ||||
|         services.AddSingleton<WebSocketService>(); | ||||
|         services.AddScoped<EmailService>(); | ||||
|         services.AddScoped<PushService>(); | ||||
|          | ||||
|         // Register QueueService as a singleton since it's thread-safe | ||||
|         services.AddSingleton<QueueService>(); | ||||
|          | ||||
|         // Register the background service | ||||
|         services.AddHostedService<QueueBackgroundService>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								DysonNetwork.Ring/appsettings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								DysonNetwork.Ring/appsettings.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| { | ||||
|   "Debug": true, | ||||
|   "BaseUrl": "http://localhost:5212", | ||||
|   "Logging": { | ||||
|     "LogLevel": { | ||||
|       "Default": "Information", | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   }, | ||||
|   "AllowedHosts": "*", | ||||
|   "ConnectionStrings": { | ||||
|     "App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", | ||||
|     "FastRetrieve": "localhost:6379", | ||||
|     "Etcd": "etcd.orb.local:2379", | ||||
|     "Stream": "nats.orb.local:4222" | ||||
|   }, | ||||
|   "Notifications": { | ||||
|     "Push": { | ||||
|       "Production": true, | ||||
|       "Google": "./Keys/Solian.json", | ||||
|       "Apple": { | ||||
|         "PrivateKey": "./Keys/Solian.p8", | ||||
|         "PrivateKeyId": "4US4KSX4W6", | ||||
|         "TeamId": "W7HPZ53V6B", | ||||
|         "BundleIdentifier": "dev.solsynth.solian" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "Email": { | ||||
|     "Server": "smtp4dev.orb.local", | ||||
|     "Port": 25, | ||||
|     "UseSsl": false, | ||||
|     "Username": "no-reply@mail.solsynth.dev", | ||||
|     "Password": "password", | ||||
|     "FromAddress": "no-reply@mail.solsynth.dev", | ||||
|     "FromName": "Alphabot", | ||||
|     "SubjectPrefix": "Solar Network" | ||||
|   }, | ||||
|   "GeoIp": { | ||||
|     "DatabasePath": "./Keys/GeoLite2-City.mmdb" | ||||
|   }, | ||||
|   "KnownProxies": [ | ||||
|     "127.0.0.1", | ||||
|     "::1" | ||||
|   ], | ||||
|   "Service": { | ||||
|     "Name": "DysonNetwork.Ring", | ||||
|     "Url": "https://localhost:7259", | ||||
|     "ClientCert": "../Certificates/client.crt", | ||||
|     "ClientKey": "../Certificates/client.key" | ||||
|   }, | ||||
|   "Etcd": { | ||||
|     "Insecure": true | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user