🔀 Merge branch 'refactor/seprate-auth'
# Conflicts: # DysonNetwork.Sphere/Chat/Realtime/LiveKitService.cs # DysonNetwork.Sphere/Chat/RealtimeCallController.cs # DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs # DysonNetwork.sln.DotSettings.user
This commit is contained in:
178
DysonNetwork.Pusher/AppDatabase.cs
Normal file
178
DysonNetwork.Pusher/AppDatabase.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pusher;
|
||||
|
||||
public class AppDatabase(
|
||||
DbContextOptions<AppDatabase> options,
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<Notification.Notification> Notifications { get; set; } = null!;
|
||||
public DbSet<PushSubscription> PushSubscriptions { get; set; }
|
||||
|
||||
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.Pusher/Connection/ClientTypeMiddleware.cs
Normal file
45
DysonNetwork.Pusher/Connection/ClientTypeMiddleware.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace DysonNetwork.Pusher.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.Pusher/Connection/IWebSocketPacketHandler.cs
Normal file
17
DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Net.WebSockets;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
public interface IWebSocketPacketHandler
|
||||
{
|
||||
string PacketType { get; }
|
||||
|
||||
Task HandleAsync(
|
||||
Account currentUser,
|
||||
string deviceId,
|
||||
WebSocketPacket packet,
|
||||
WebSocket socket,
|
||||
WebSocketService srv
|
||||
);
|
||||
}
|
107
DysonNetwork.Pusher/Connection/WebSocketController.cs
Normal file
107
DysonNetwork.Pusher/Connection/WebSocketController.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Net.WebSockets;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
[ApiController]
|
||||
[Route("/ws")]
|
||||
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!;
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||
var cts = new CancellationTokenSource();
|
||||
var connectionKey = (accountId, deviceId);
|
||||
|
||||
if (!ws.TryAdd(connectionKey, webSocket, cts))
|
||||
{
|
||||
await webSocket.CloseAsync(
|
||||
WebSocketCloseStatus.PolicyViolation,
|
||||
"Too many connections from the same device and account.",
|
||||
CancellationToken.None
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
|
||||
|
||||
try
|
||||
{
|
||||
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"WebSocket Error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ws.Disconnect(connectionKey);
|
||||
logger.LogInformation(
|
||||
$"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
|
||||
{
|
||||
var receiveResult = await webSocket.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer),
|
||||
cancellationToken
|
||||
);
|
||||
while (!receiveResult.CloseStatus.HasValue)
|
||||
{
|
||||
receiveResult = await webSocket.ReceiveAsync(
|
||||
new ArraySegment<byte>(buffer),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
|
||||
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (
|
||||
webSocket.State != WebSocketState.Closed
|
||||
&& webSocket.State != WebSocketState.Aborted
|
||||
)
|
||||
{
|
||||
ws.Disconnect(connectionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
87
DysonNetwork.Pusher/Connection/WebSocketPacket.cs
Normal file
87
DysonNetwork.Pusher/Connection/WebSocketPacket.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
public class WebSocketPacket
|
||||
{
|
||||
public string Type { get; set; } = null!;
|
||||
public object? Data { get; set; } = null!;
|
||||
public string? Endpoint { get; set; }
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
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.ConvertClassToValue(Data),
|
||||
ErrorMessage = ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
public static WebSocketPacket FromProtoValue(Shared.Proto.WebSocketPacket packet)
|
||||
{
|
||||
return new WebSocketPacket
|
||||
{
|
||||
Type = packet.Type,
|
||||
Data = GrpcTypeHelper.ConvertValueToObject(packet.Data),
|
||||
ErrorMessage = packet.ErrorMessage
|
||||
};
|
||||
}
|
||||
}
|
179
DysonNetwork.Pusher/Connection/WebSocketService.cs
Normal file
179
DysonNetwork.Pusher/Connection/WebSocketService.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
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.Pusher.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;
|
||||
data.Socket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
reason ?? "Server just decided to disconnect.",
|
||||
CancellationToken.None
|
||||
);
|
||||
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 (_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 PusherHandlerService.PusherHandlerServiceClient(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
|
||||
);
|
||||
}
|
||||
}
|
23
DysonNetwork.Pusher/Dockerfile
Normal file
23
DysonNetwork.Pusher/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["DysonNetwork.Pusher/DysonNetwork.Pusher.csproj", "DysonNetwork.Pusher/"]
|
||||
RUN dotnet restore "DysonNetwork.Pusher/DysonNetwork.Pusher.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/DysonNetwork.Pusher"
|
||||
RUN dotnet build "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "DysonNetwork.Pusher.dll"]
|
47
DysonNetwork.Pusher/DysonNetwork.Pusher.csproj
Normal file
47
DysonNetwork.Pusher/DysonNetwork.Pusher.csproj
Normal file
@@ -0,0 +1,47 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</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>
|
74
DysonNetwork.Pusher/Email/EmailService.cs
Normal file
74
DysonNetwork.Pusher/Email/EmailService.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MimeKit;
|
||||
|
||||
namespace DysonNetwork.Pusher.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}";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.Pusher/Migrations/20250713122638_InitialMigration.Designer.cs
generated
Normal file
151
DysonNetwork.Pusher/Migrations/20250713122638_InitialMigration.Designer.cs
generated
Normal file
@@ -0,0 +1,151 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Pusher;
|
||||
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.Pusher.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.Pusher.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.Pusher.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.Pusher.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");
|
||||
}
|
||||
}
|
||||
}
|
148
DysonNetwork.Pusher/Migrations/AppDatabaseModelSnapshot.cs
Normal file
148
DysonNetwork.Pusher/Migrations/AppDatabaseModelSnapshot.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Pusher;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pusher.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.Pusher.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.Pusher.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
|
||||
}
|
||||
}
|
||||
}
|
23
DysonNetwork.Pusher/Notification/Notification.cs
Normal file
23
DysonNetwork.Pusher/Notification/Notification.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pusher.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; }
|
||||
public int Priority { get; set; } = 10;
|
||||
public Instant? ViewedAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
}
|
||||
|
152
DysonNetwork.Pusher/Notification/NotificationController.cs
Normal file
152
DysonNetwork.Pusher/Notification/NotificationController.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
using AccountService = DysonNetwork.Shared.Proto.AccountService;
|
||||
|
||||
namespace DysonNetwork.Pusher.Notification;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/notifications")]
|
||||
public class NotificationController(
|
||||
AppDatabase db,
|
||||
PushService nty,
|
||||
AccountService.AccountServiceClient accounts) : 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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
294
DysonNetwork.Pusher/Notification/PushService.cs
Normal file
294
DysonNetwork.Pusher/Notification/PushService.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
using CorePush.Apple;
|
||||
using CorePush.Firebase;
|
||||
using DysonNetwork.Pusher.Connection;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pusher.Notification;
|
||||
|
||||
public class PushService
|
||||
{
|
||||
private readonly AppDatabase _db;
|
||||
private readonly WebSocketService _ws;
|
||||
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,
|
||||
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;
|
||||
_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)
|
||||
{
|
||||
// Update existing subscription
|
||||
existingSubscription.DeviceId = deviceId;
|
||||
existingSubscription.DeviceToken = deviceToken;
|
||||
existingSubscription.Provider = provider;
|
||||
existingSubscription.UpdatedAt = now;
|
||||
|
||||
_db.Update(existingSubscription);
|
||||
await _db.SaveChangesAsync();
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
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)
|
||||
{
|
||||
if (title is null && subtitle is null && content is null)
|
||||
throw new ArgumentException("Unable to send notification that 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.Add(notification);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!isSilent) _ = DeliveryNotification(notification);
|
||||
}
|
||||
|
||||
public async Task DeliveryNotification(Notification notification)
|
||||
{
|
||||
// Pushing the notification
|
||||
var subscribers = await _db.PushSubscriptions
|
||||
.Where(s => s.AccountId == notification.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
|
||||
if (id.Count == 0) return;
|
||||
|
||||
await _db.Notifications
|
||||
.Where(n => id.Contains(n.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
var notifications = accounts.Select(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
AccountId = x
|
||||
};
|
||||
return newNotification;
|
||||
}).ToList();
|
||||
await _db.BulkInsertAsync(notifications);
|
||||
}
|
||||
|
||||
var subscribers = await _db.PushSubscriptions
|
||||
.Where(s => accounts.Contains(s.AccountId))
|
||||
.ToListAsync();
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
private async Task _PushNotification(
|
||||
Notification notification,
|
||||
IEnumerable<PushSubscription> subscriptions
|
||||
)
|
||||
{
|
||||
var tasks = subscriptions
|
||||
.Select(subscription => _PushSingleNotification(notification, subscription))
|
||||
.ToList();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task _PushSingleNotification(Notification notification, PushSubscription subscription)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
["data"] = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = notification.Id,
|
||||
["topic"] = notification.Topic,
|
||||
["meta"] = notification.Meta ?? new Dictionary<string, object>()
|
||||
}
|
||||
}
|
||||
});
|
||||
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,
|
||||
["aps"] = new Dictionary<string, object?>
|
||||
{
|
||||
["alert"] = alertDict,
|
||||
["sound"] = notification.Priority >= 5 ? "default" : null,
|
||||
["mutable-content"] = 1
|
||||
},
|
||||
["meta"] = notification.Meta ?? new Dictionary<string, object>()
|
||||
};
|
||||
|
||||
await _apns.SendAsync(
|
||||
payload,
|
||||
deviceToken: subscription.DeviceToken,
|
||||
apnsId: notification.Id.ToString(),
|
||||
apnsPriority: notification.Priority,
|
||||
apnPushType: ApnPushType.Alert
|
||||
);
|
||||
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}");
|
||||
throw new Exception($"Failed to send notification to {subscription.Provider}: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId}");
|
||||
}
|
||||
}
|
25
DysonNetwork.Pusher/Notification/PushSubscription.cs
Normal file
25
DysonNetwork.Pusher/Notification/PushSubscription.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pusher.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; }
|
||||
}
|
46
DysonNetwork.Pusher/Program.cs
Normal file
46
DysonNetwork.Pusher/Program.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using DysonNetwork.Pusher;
|
||||
using DysonNetwork.Pusher.Startup;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel();
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddRegistryService(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();
|
||||
builder.Services.AddPushServices(builder.Configuration);
|
||||
|
||||
// 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.Pusher/Properties/launchSettings.json
Normal file
23
DysonNetwork.Pusher/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
136
DysonNetwork.Pusher/Services/PusherServiceGrpc.cs
Normal file
136
DysonNetwork.Pusher/Services/PusherServiceGrpc.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using DysonNetwork.Pusher.Connection;
|
||||
using DysonNetwork.Pusher.Email;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pusher.Services;
|
||||
|
||||
public class PusherServiceGrpc(
|
||||
EmailService emailService,
|
||||
WebSocketService websocket,
|
||||
PushService pushService,
|
||||
AccountClientHelper accountsHelper
|
||||
) : PusherService.PusherServiceBase
|
||||
{
|
||||
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
||||
{
|
||||
await emailService.SendEmailAsync(
|
||||
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 = 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 = 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 = 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 = 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 account = await accountsHelper.GetAccount(Guid.Parse(request.UserId));
|
||||
await pushService.SendNotification(
|
||||
account,
|
||||
request.Notification.Topic,
|
||||
request.Notification.Title,
|
||||
request.Notification.Subtitle,
|
||||
request.Notification.Body,
|
||||
GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta),
|
||||
request.Notification.ActionUri,
|
||||
request.Notification.IsSilent,
|
||||
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 = GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta),
|
||||
};
|
||||
if (request.Notification.ActionUri is not null)
|
||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
||||
var accounts = request.UserIds.Select(Guid.Parse).ToList();
|
||||
await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable);
|
||||
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 });
|
||||
}
|
||||
}
|
67
DysonNetwork.Pusher/Startup/ApplicationConfiguration.cs
Normal file
67
DysonNetwork.Pusher/Startup/ApplicationConfiguration.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Pusher.Services;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
namespace DysonNetwork.Pusher.Startup;
|
||||
|
||||
public static class ApplicationConfiguration
|
||||
{
|
||||
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseRequestLocalization();
|
||||
|
||||
ConfigureForwardedHeaders(app, 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;
|
||||
}
|
||||
|
||||
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
var knownProxiesSection = configuration.GetSection("KnownProxies");
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
|
||||
|
||||
if (knownProxiesSection.Exists())
|
||||
{
|
||||
var proxyAddresses = knownProxiesSection.Get<string[]>();
|
||||
if (proxyAddresses != null)
|
||||
foreach (var proxy in proxyAddresses)
|
||||
if (IPAddress.TryParse(proxy, out var ipAddress))
|
||||
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
}
|
||||
|
||||
public static WebApplication ConfigureGrpcServices(this WebApplication app)
|
||||
{
|
||||
app.MapGrpcService<PusherServiceGrpc>();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
17
DysonNetwork.Pusher/Startup/KestrelConfiguration.cs
Normal file
17
DysonNetwork.Pusher/Startup/KestrelConfiguration.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace DysonNetwork.Pusher.Startup;
|
||||
|
||||
public static class KestrelConfiguration
|
||||
{
|
||||
public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
22
DysonNetwork.Pusher/Startup/ScheduledJobsConfiguration.cs
Normal file
22
DysonNetwork.Pusher/Startup/ScheduledJobsConfiguration.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pusher.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 * * ?"));
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
151
DysonNetwork.Pusher/Startup/ServiceCollectionExtensions.cs
Normal file
151
DysonNetwork.Pusher/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using CorePush.Apple;
|
||||
using CorePush.Firebase;
|
||||
using DysonNetwork.Pusher.Connection;
|
||||
using DysonNetwork.Pusher.Email;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Pusher.Services;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace DysonNetwork.Pusher.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<PusherServiceGrpc>();
|
||||
|
||||
// Register OIDC services
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
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 Pusher",
|
||||
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>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<WebSocketService>();
|
||||
services.AddScoped<EmailService>();
|
||||
services.AddScoped<PushService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static void AddPushServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<ApnSettings>(configuration.GetSection("PushNotify:Apple"));
|
||||
services.AddHttpClient<ApnSender>();
|
||||
|
||||
services.Configure<FirebaseSettings>(configuration.GetSection("PushNotify:Firebase"));
|
||||
services.AddHttpClient<FirebaseSettings>();
|
||||
}
|
||||
}
|
54
DysonNetwork.Pusher/appsettings.json
Normal file
54
DysonNetwork.Pusher/appsettings.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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.Pusher",
|
||||
"Url": "https://localhost:7259",
|
||||
"ClientCert": "../Certificates/client.crt",
|
||||
"ClientKey": "../Certificates/client.key"
|
||||
},
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user