🔀 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:
2025-07-20 02:28:42 +08:00
388 changed files with 26638 additions and 105969 deletions

View 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;
}
}

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

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

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

View 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
};
}
}

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

View 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"]

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

View 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;
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View 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; }
}

View 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();
}
}

View 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}");
}
}

View 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; }
}

View 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();

View 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"
}
}
}
}

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

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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>();
}
}

View 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
}
}