Merge branch 'master' into a123lsw-patch-1
This commit is contained in:
@@ -9,11 +9,11 @@ public class AppDatabase(
|
|||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
) : DbContext(options)
|
) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<Developer> Developers { get; set; }
|
public DbSet<Developer> Developers { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<CustomApp> CustomApps { get; set; }
|
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
||||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
|
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
optionsBuilder.UseNpgsql(
|
optionsBuilder.UseNpgsql(
|
||||||
|
@@ -8,9 +8,6 @@ using Google.Protobuf.WellKnownTypes;
|
|||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
using DysonNetwork.Shared.Proto;
|
|
||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
using NodaTime.Serialization.Protobuf;
|
|
||||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||||
|
|
||||||
namespace DysonNetwork.Develop.Identity;
|
namespace DysonNetwork.Develop.Identity;
|
||||||
@@ -134,8 +131,8 @@ public class CustomAppSecret : ModelBase
|
|||||||
|
|
||||||
public Guid AppId { get; set; }
|
public Guid AppId { get; set; }
|
||||||
public CustomApp App { get; set; } = null!;
|
public CustomApp App { get; set; } = null!;
|
||||||
|
|
||||||
|
|
||||||
public static CustomAppSecret FromProtoValue(DysonNetwork.Shared.Proto.CustomAppSecret p)
|
public static CustomAppSecret FromProtoValue(DysonNetwork.Shared.Proto.CustomAppSecret p)
|
||||||
{
|
{
|
||||||
return new CustomAppSecret
|
return new CustomAppSecret
|
||||||
@@ -161,4 +158,4 @@ public class CustomAppSecret : ModelBase
|
|||||||
AppId = Id.ToString(),
|
AppId = Id.ToString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -45,7 +45,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
|
|
||||||
/// The object name which stored remotely,
|
/// The object name which stored remotely,
|
||||||
/// multiple cloud file may have same storage id to indicate they are the same file
|
/// multiple cloud file may have same storage id to indicate they are the same file
|
||||||
///
|
///
|
||||||
/// If the storage id was null and the uploaded at is not null, means it is an embedding file,
|
/// If the storage id was null and the uploaded at is not null, means it is an embedding file,
|
||||||
/// The embedding file means the file is store on another site,
|
/// The embedding file means the file is store on another site,
|
||||||
/// or it is a webpage (based on mimetype)
|
/// or it is a webpage (based on mimetype)
|
||||||
@@ -72,8 +72,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
DeletedAt = DeletedAt,
|
DeletedAt = DeletedAt,
|
||||||
Id = Id,
|
Id = Id,
|
||||||
Name = Name,
|
Name = Name,
|
||||||
FileMeta = FileMeta,
|
FileMeta = FileMeta ?? [],
|
||||||
UserMeta = UserMeta,
|
UserMeta = UserMeta ?? [],
|
||||||
SensitiveMarks = SensitiveMarks,
|
SensitiveMarks = SensitiveMarks,
|
||||||
MimeType = MimeType,
|
MimeType = MimeType,
|
||||||
Hash = Hash,
|
Hash = Hash,
|
||||||
@@ -141,4 +141,4 @@ public class CloudFileReference : ModelBase
|
|||||||
ExpiredAt = ExpiredAt?.ToTimestamp()
|
ExpiredAt = ExpiredAt?.ToTimestamp()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -20,7 +20,6 @@ namespace DysonNetwork.Drive.Storage;
|
|||||||
public class FileService(
|
public class FileService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
TusDiskStore store,
|
|
||||||
ILogger<FileService> logger,
|
ILogger<FileService> logger,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ICacheService cache
|
ICacheService cache
|
||||||
@@ -268,14 +267,26 @@ public class FileService(
|
|||||||
// Add detailed stream information
|
// Add detailed stream information
|
||||||
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
||||||
{
|
{
|
||||||
s.AvgFrameRate, s.BitRate, s.CodecName, s.Duration, s.Height, s.Width, s.Language,
|
s.AvgFrameRate,
|
||||||
s.PixelFormat, s.Rotation
|
s.BitRate,
|
||||||
|
s.CodecName,
|
||||||
|
s.Duration,
|
||||||
|
s.Height,
|
||||||
|
s.Width,
|
||||||
|
s.Language,
|
||||||
|
s.PixelFormat,
|
||||||
|
s.Rotation
|
||||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||||
{
|
{
|
||||||
s.BitRate, s.Channels, s.ChannelLayout, s.CodecName, s.Duration, s.Language,
|
s.BitRate,
|
||||||
s.SampleRateHz
|
s.Channels,
|
||||||
})
|
s.ChannelLayout,
|
||||||
|
s.CodecName,
|
||||||
|
s.Duration,
|
||||||
|
s.Language,
|
||||||
|
s.SampleRateHz
|
||||||
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
};
|
};
|
||||||
if (mediaInfo.PrimaryVideoStream is not null)
|
if (mediaInfo.PrimaryVideoStream is not null)
|
||||||
@@ -319,7 +330,7 @@ public class FileService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogInformation("Processing file {FileId} in background...", fileId);
|
logger.LogInformation("Processing file {FileId} in background...", fileId);
|
||||||
|
|
||||||
var fileExtension = Path.GetExtension(originalFilePath);
|
var fileExtension = Path.GetExtension(originalFilePath);
|
||||||
|
|
||||||
if (!pool.PolicyConfig.NoOptimization)
|
if (!pool.PolicyConfig.NoOptimization)
|
||||||
@@ -869,4 +880,4 @@ file class UpdatableCloudFile(CloudFile file)
|
|||||||
.SetProperty(f => f.UserMeta, userMeta!)
|
.SetProperty(f => f.UserMeta, userMeta!)
|
||||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Pass.Permission;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Pass.Wallet;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
@@ -9,7 +10,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using NodaTime;
|
using NodaTime;
|
||||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||||
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
|
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
|
||||||
using ChallengePlatform = DysonNetwork.Pass.Auth.ChallengePlatform;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
@@ -437,29 +437,31 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AuthorizedDevice
|
|
||||||
{
|
|
||||||
public string? Label { get; set; }
|
|
||||||
public string UserAgent { get; set; } = null!;
|
|
||||||
public string DeviceId { get; set; } = null!;
|
|
||||||
public ChallengePlatform Platform { get; set; }
|
|
||||||
public List<AuthSession> Sessions { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("devices")]
|
[HttpGet("devices")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
|
public async Task<ActionResult<List<AuthClientWithChallenge>>> GetDevices()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
var devices = await db.AuthDevices
|
var devices = await db.AuthClients
|
||||||
.Where(device => device.AccountId == currentUser.Id)
|
.Where(device => device.AccountId == currentUser.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Ok(devices);
|
var challengeDevices = devices.Select(AuthClientWithChallenge.FromClient).ToList();
|
||||||
|
var deviceIds = challengeDevices.Select(x => x.Id).ToList();
|
||||||
|
|
||||||
|
var authChallenges = await db.AuthChallenges
|
||||||
|
.Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value))
|
||||||
|
.GroupBy(c => c.ClientId)
|
||||||
|
.ToDictionaryAsync(c => c.Key!.Value, c => c.ToList());
|
||||||
|
foreach (var challengeDevice in challengeDevices)
|
||||||
|
if (authChallenges.TryGetValue(challengeDevice.Id, out var challenge))
|
||||||
|
challengeDevice.Challenges = challenge;
|
||||||
|
|
||||||
|
return Ok(challengeDevices);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sessions")]
|
[HttpGet("sessions")]
|
||||||
@@ -507,6 +509,23 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("devices/{deviceId}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AuthSession>> DeleteDevice(string deviceId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.DeleteDevice(currentUser, deviceId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("sessions/current")]
|
[HttpDelete("sessions/current")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
|
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
|
||||||
@@ -525,14 +544,15 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("sessions/{id:guid}/label")]
|
[HttpPatch("devices/{deviceId}/label")]
|
||||||
public async Task<ActionResult<AuthSession>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await accounts.UpdateSessionLabel(currentUser, id, label);
|
await accounts.UpdateDeviceName(currentUser, deviceId, label);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -541,15 +561,19 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("sessions/current/label")]
|
[HttpPatch("devices/current/label")]
|
||||||
public async Task<ActionResult<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label)
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
|
||||||
|
if (device is null) return NotFound();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
|
await accounts.UpdateDeviceName(currentUser, device.DeviceId, label);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -637,7 +661,7 @@ public class AccountCurrentController(
|
|||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("contacts/{id:guid}/public")]
|
[HttpPost("contacts/{id:guid}/public")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> SetPublicContact(Guid id)
|
public async Task<ActionResult<AccountContact>> SetPublicContact(Guid id)
|
||||||
@@ -659,7 +683,7 @@ public class AccountCurrentController(
|
|||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("contacts/{id:guid}/public")]
|
[HttpDelete("contacts/{id:guid}/public")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<AccountContact>> UnsetPublicContact(Guid id)
|
public async Task<ActionResult<AccountContact>> UnsetPublicContact(Guid id)
|
||||||
@@ -733,4 +757,4 @@ public class AccountCurrentController(
|
|||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
@@ -458,37 +457,30 @@ public class AccountService(
|
|||||||
|
|
||||||
public async Task<bool> IsDeviceActive(Guid id)
|
public async Task<bool> IsDeviceActive(Guid id)
|
||||||
{
|
{
|
||||||
return await db.AuthChallenges.AnyAsync(d => d.DeviceId == id);
|
return await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.AnyAsync(s => s.Challenge.ClientId == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthSession> UpdateSessionLabel(Account account, Guid sessionId, string label)
|
public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
|
||||||
{
|
{
|
||||||
var session = await db.AuthSessions
|
var device = await db.AuthClients.FirstOrDefaultAsync(
|
||||||
.Include(s => s.Challenge)
|
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
);
|
||||||
.FirstOrDefaultAsync();
|
if (device is null) throw new InvalidOperationException("Device was not found.");
|
||||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
|
||||||
|
|
||||||
await db.AuthSessions
|
device.DeviceLabel = label;
|
||||||
.Include(s => s.Challenge)
|
db.Update(device);
|
||||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
await db.SaveChangesAsync();
|
||||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
|
|
||||||
|
|
||||||
var sessions = await db.AuthSessions
|
return device;
|
||||||
.Include(s => s.Challenge)
|
|
||||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
|
||||||
.ToListAsync();
|
|
||||||
foreach (var item in sessions)
|
|
||||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteSession(Account account, Guid sessionId)
|
public async Task DeleteSession(Account account, Guid sessionId)
|
||||||
{
|
{
|
||||||
var session = await db.AuthSessions
|
var session = await db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.Include(s => s.Challenge)
|
||||||
.ThenInclude(s => s.Device)
|
.ThenInclude(s => s.Client)
|
||||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
@@ -498,10 +490,13 @@ public class AccountService(
|
|||||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (!await IsDeviceActive(session.Challenge.DeviceId))
|
if (session.Challenge.ClientId.HasValue)
|
||||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
{
|
||||||
{ DeviceId = session.Challenge.Device.DeviceId }
|
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||||
);
|
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||||
|
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The current session should be included in the sessions' list
|
// The current session should be included in the sessions' list
|
||||||
await db.AuthSessions
|
await db.AuthSessions
|
||||||
@@ -513,6 +508,36 @@ public class AccountService(
|
|||||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task DeleteDevice(Account account, string deviceId)
|
||||||
|
{
|
||||||
|
var device = await db.AuthClients.FirstOrDefaultAsync(
|
||||||
|
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||||
|
);
|
||||||
|
if (device is null)
|
||||||
|
throw new InvalidOperationException("Device not found.");
|
||||||
|
|
||||||
|
await pusher.UnsubscribePushNotificationsAsync(
|
||||||
|
new UnsubscribePushNotificationsRequest() { DeviceId = device.DeviceId }
|
||||||
|
);
|
||||||
|
|
||||||
|
db.AuthClients.Remove(device);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sessions = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Challenge.ClientId == device.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// The current session should be included in the sessions' list
|
||||||
|
await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Challenge.DeviceId == device.DeviceId)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
foreach (var item in sessions)
|
||||||
|
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
|
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
|
||||||
{
|
{
|
||||||
var isExists = await db.AccountContacts
|
var isExists = await db.AccountContacts
|
||||||
@@ -679,4 +704,4 @@ public class AccountService(
|
|||||||
await db.BulkInsertAsync(newProfiles);
|
await db.BulkInsertAsync(newProfiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -18,35 +18,35 @@ public class AppDatabase(
|
|||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
) : DbContext(options)
|
) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<PermissionNode> PermissionNodes { get; set; }
|
public DbSet<PermissionNode> PermissionNodes { get; set; } = null!;
|
||||||
public DbSet<PermissionGroup> PermissionGroups { get; set; }
|
public DbSet<PermissionGroup> PermissionGroups { get; set; } = null!;
|
||||||
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<MagicSpell> MagicSpells { get; set; }
|
public DbSet<MagicSpell> MagicSpells { get; set; } = null!;
|
||||||
public DbSet<Account.Account> Accounts { get; set; }
|
public DbSet<Account.Account> Accounts { get; set; } = null!;
|
||||||
public DbSet<AccountConnection> AccountConnections { get; set; }
|
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
|
||||||
public DbSet<AccountProfile> AccountProfiles { get; set; }
|
public DbSet<AccountProfile> AccountProfiles { get; set; } = null!;
|
||||||
public DbSet<AccountContact> AccountContacts { get; set; }
|
public DbSet<AccountContact> AccountContacts { get; set; } = null!;
|
||||||
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
|
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
|
||||||
public DbSet<Relationship> AccountRelationships { get; set; }
|
public DbSet<Relationship> AccountRelationships { get; set; } = null!;
|
||||||
public DbSet<Status> AccountStatuses { get; set; }
|
public DbSet<Status> AccountStatuses { get; set; } = null!;
|
||||||
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
|
public DbSet<CheckInResult> AccountCheckInResults { get; set; } = null!;
|
||||||
public DbSet<AccountBadge> Badges { get; set; }
|
public DbSet<AccountBadge> Badges { get; set; } = null!;
|
||||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
public DbSet<ActionLog> ActionLogs { get; set; } = null!;
|
||||||
public DbSet<AbuseReport> AbuseReports { get; set; }
|
public DbSet<AbuseReport> AbuseReports { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<AuthSession> AuthSessions { get; set; }
|
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||||
public DbSet<AuthChallenge> AuthChallenges { get; set; }
|
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||||
public DbSet<AuthDevice> AuthDevices { get; set; }
|
public DbSet<AuthClient> AuthClients { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Wallet.Wallet> Wallets { get; set; }
|
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||||
public DbSet<WalletPocket> WalletPockets { get; set; }
|
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
||||||
public DbSet<Order> PaymentOrders { get; set; }
|
public DbSet<Order> PaymentOrders { get; set; } = null!;
|
||||||
public DbSet<Transaction> PaymentTransactions { get; set; }
|
public DbSet<Transaction> PaymentTransactions { get; set; } = null!;
|
||||||
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
public DbSet<Subscription> WalletSubscriptions { get; set; } = null!;
|
||||||
public DbSet<Coupon> WalletCoupons { get; set; }
|
public DbSet<Coupon> WalletCoupons { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Punishment> Punishments { get; set; }
|
public DbSet<Punishment> Punishments { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ public class AppDatabase(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
optionsBuilder.UseSeeding((context, _) => {});
|
optionsBuilder.UseSeeding((context, _) => { });
|
||||||
|
|
||||||
base.OnConfiguring(optionsBuilder);
|
base.OnConfiguring(optionsBuilder);
|
||||||
}
|
}
|
||||||
@@ -270,4 +270,4 @@ public static class OptionalQueryExtensions
|
|||||||
{
|
{
|
||||||
return condition ? transform(source) : source;
|
return condition ? transform(source) : source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -75,6 +75,7 @@ public class DysonTokenAuthHandler(
|
|||||||
session = await database.AuthSessions
|
session = await database.AuthSessions
|
||||||
.Where(e => e.Id == sessionId)
|
.Where(e => e.Id == sessionId)
|
||||||
.Include(e => e.Challenge)
|
.Include(e => e.Challenge)
|
||||||
|
.ThenInclude(e => e.Client)
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
.ThenInclude(e => e.Profile)
|
.ThenInclude(e => e.Profile)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
@@ -156,14 +157,14 @@ public class DysonTokenAuthHandler(
|
|||||||
{
|
{
|
||||||
// Handle JWT tokens (3 parts)
|
// Handle JWT tokens (3 parts)
|
||||||
case 3:
|
case 3:
|
||||||
{
|
{
|
||||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||||
if (!isValid) return false;
|
if (!isValid) return false;
|
||||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||||
if (jti is null) return false;
|
if (jti is null) return false;
|
||||||
|
|
||||||
return Guid.TryParse(jti, out sessionId);
|
return Guid.TryParse(jti, out sessionId);
|
||||||
}
|
}
|
||||||
// Handle compact tokens (2 parts)
|
// Handle compact tokens (2 parts)
|
||||||
case 2:
|
case 2:
|
||||||
// Original compact token validation logic
|
// Original compact token validation logic
|
||||||
@@ -189,8 +190,6 @@ public class DysonTokenAuthHandler(
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -276,4 +275,4 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -20,18 +20,19 @@ public class AuthController(
|
|||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
|
private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
|
||||||
|
|
||||||
public class ChallengeRequest
|
public class ChallengeRequest
|
||||||
{
|
{
|
||||||
[Required] public ChallengePlatform Platform { get; set; }
|
[Required] public ClientPlatform Platform { get; set; }
|
||||||
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
|
[Required][MaxLength(256)] public string Account { get; set; } = null!;
|
||||||
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
[Required][MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||||
|
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||||
public List<string> Audiences { get; set; } = new();
|
public List<string> Audiences { get; set; } = new();
|
||||||
public List<string> Scopes { get; set; } = new();
|
public List<string> Scopes { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("challenge")]
|
[HttpPost("challenge")]
|
||||||
public async Task<ActionResult<AuthChallenge>> StartChallenge([FromBody] ChallengeRequest request)
|
public async Task<ActionResult<AuthChallenge>> CreateChallenge([FromBody] ChallengeRequest request)
|
||||||
{
|
{
|
||||||
var account = await accounts.LookupAccount(request.Account);
|
var account = await accounts.LookupAccount(request.Account);
|
||||||
if (account is null) return NotFound("Account was not found.");
|
if (account is null) return NotFound("Account was not found.");
|
||||||
@@ -47,6 +48,10 @@ public class AuthController(
|
|||||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||||
|
|
||||||
|
request.DeviceName ??= userAgent;
|
||||||
|
|
||||||
|
var device = await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
|
||||||
|
|
||||||
// Trying to pick up challenges from the same IP address and user agent
|
// Trying to pick up challenges from the same IP address and user agent
|
||||||
var existingChallenge = await db.AuthChallenges
|
var existingChallenge = await db.AuthChallenges
|
||||||
.Where(e => e.AccountId == account.Id)
|
.Where(e => e.AccountId == account.Id)
|
||||||
@@ -54,21 +59,25 @@ public class AuthController(
|
|||||||
.Where(e => e.UserAgent == userAgent)
|
.Where(e => e.UserAgent == userAgent)
|
||||||
.Where(e => e.StepRemain > 0)
|
.Where(e => e.StepRemain > 0)
|
||||||
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
||||||
|
.Where(e => e.Type == ChallengeType.Login)
|
||||||
|
.Where(e => e.ClientId == device.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (existingChallenge is not null) return existingChallenge;
|
if (existingChallenge is not null)
|
||||||
|
{
|
||||||
|
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id).FirstOrDefaultAsync();
|
||||||
|
if (existingSession is null) return existingChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
var device = await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId);
|
|
||||||
var challenge = new AuthChallenge
|
var challenge = new AuthChallenge
|
||||||
{
|
{
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||||
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
||||||
Platform = request.Platform,
|
|
||||||
Audiences = request.Audiences,
|
Audiences = request.Audiences,
|
||||||
Scopes = request.Scopes,
|
Scopes = request.Scopes,
|
||||||
IpAddress = ipAddress,
|
IpAddress = ipAddress,
|
||||||
UserAgent = userAgent,
|
UserAgent = userAgent,
|
||||||
Location = geo.GetPointFromIp(ipAddress),
|
Location = geo.GetPointFromIp(ipAddress),
|
||||||
DeviceId = device.Id,
|
ClientId = device.Id,
|
||||||
AccountId = account.Id
|
AccountId = account.Id
|
||||||
}.Normalize();
|
}.Normalize();
|
||||||
|
|
||||||
@@ -295,4 +304,4 @@ public class AuthController(
|
|||||||
});
|
});
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,17 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using DysonNetwork.Shared.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Auth;
|
|
||||||
|
|
||||||
[Index(nameof(DeviceId), IsUnique = true)]
|
|
||||||
public class AuthDevice : ModelBase
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
[MaxLength(1024)] public string DeviceName { get; set; } = string.Empty;
|
|
||||||
[MaxLength(1024)] public string DeviceId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
|
||||||
}
|
|
@@ -12,7 +12,8 @@ public class AuthService(
|
|||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ICacheService cache
|
ICacheService cache,
|
||||||
|
ILogger<AuthService> logger
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||||
@@ -73,7 +74,8 @@ public class AuthService(
|
|||||||
return totalRequiredSteps;
|
return totalRequiredSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
|
||||||
|
Guid? customAppId = null)
|
||||||
{
|
{
|
||||||
var challenge = new AuthChallenge
|
var challenge = new AuthChallenge
|
||||||
{
|
{
|
||||||
@@ -100,17 +102,24 @@ public class AuthService(
|
|||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuthDevice> GetOrCreateDeviceAsync(Guid accountId, string deviceId)
|
public async Task<AuthClient> GetOrCreateDeviceAsync(
|
||||||
|
Guid accountId,
|
||||||
|
string deviceId,
|
||||||
|
string? deviceName = null,
|
||||||
|
ClientPlatform platform = ClientPlatform.Unidentified
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var device = await db.AuthDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
||||||
if (device is not null) return device;
|
if (device is not null) return device;
|
||||||
device = new AuthDevice
|
device = new AuthClient
|
||||||
{
|
{
|
||||||
|
Platform = platform,
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
AccountId = accountId
|
AccountId = accountId
|
||||||
};
|
};
|
||||||
db.AuthDevices.Add(device);
|
if (deviceName is not null) device.DeviceName = deviceName;
|
||||||
|
db.AuthClients.Add(device);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return device;
|
return device;
|
||||||
@@ -203,43 +212,43 @@ public class AuthService(
|
|||||||
// Check if the session is already in sudo mode (cached)
|
// Check if the session is already in sudo mode (cached)
|
||||||
var sudoModeKey = $"accounts:{session.Id}:sudo";
|
var sudoModeKey = $"accounts:{session.Id}:sudo";
|
||||||
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
|
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
|
||||||
|
|
||||||
if (found)
|
if (found)
|
||||||
{
|
{
|
||||||
// Session is already in sudo mode
|
// Session is already in sudo mode
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user has a pin code
|
// Check if the user has a pin code
|
||||||
var hasPinCode = await db.AccountAuthFactors
|
var hasPinCode = await db.AccountAuthFactors
|
||||||
.Where(f => f.AccountId == session.AccountId)
|
.Where(f => f.AccountId == session.AccountId)
|
||||||
.Where(f => f.EnabledAt != null)
|
.Where(f => f.EnabledAt != null)
|
||||||
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
||||||
.AnyAsync();
|
.AnyAsync();
|
||||||
|
|
||||||
if (!hasPinCode)
|
if (!hasPinCode)
|
||||||
{
|
{
|
||||||
// User doesn't have a pin code, no validation needed
|
// User doesn't have a pin code, no validation needed
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If pin code is not provided, we can't validate
|
// If pin code is not provided, we can't validate
|
||||||
if (string.IsNullOrEmpty(pinCode))
|
if (string.IsNullOrEmpty(pinCode))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Validate the pin code
|
// Validate the pin code
|
||||||
var isValid = await ValidatePinCode(session.AccountId, pinCode);
|
var isValid = await ValidatePinCode(session.AccountId, pinCode);
|
||||||
|
|
||||||
if (isValid)
|
if (isValid)
|
||||||
{
|
{
|
||||||
// Set session in sudo mode for 5 minutes
|
// Set session in sudo mode for 5 minutes
|
||||||
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
|
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
return isValid;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException)
|
catch (InvalidOperationException)
|
||||||
@@ -293,6 +302,49 @@ public class AuthService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task MigrateDeviceIdToClient()
|
||||||
|
{
|
||||||
|
logger.LogInformation("Migrating device IDs to clients...");
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var challenges = await db.AuthChallenges
|
||||||
|
.Where(c => c.DeviceId != null && c.ClientId == null)
|
||||||
|
.ToListAsync();
|
||||||
|
var clients = challenges.GroupBy(c => c.DeviceId)
|
||||||
|
.Select(c => new AuthClient
|
||||||
|
{
|
||||||
|
DeviceId = c.Key!,
|
||||||
|
AccountId = c.First().AccountId,
|
||||||
|
DeviceName = c.First().UserAgent ?? string.Empty,
|
||||||
|
Platform = ClientPlatform.Unidentified
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
await db.AuthClients.AddRangeAsync(clients);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var clientsMap = clients.ToDictionary(c => c.DeviceId, c => c.Id);
|
||||||
|
foreach (var challenge in challenges.Where(challenge => challenge.ClientId == null && challenge.DeviceId != null))
|
||||||
|
{
|
||||||
|
if (clientsMap.TryGetValue(challenge.DeviceId!, out var clientId))
|
||||||
|
challenge.ClientId = clientId;
|
||||||
|
db.AuthChallenges.Update(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
logger.LogInformation("Migrated {Count} device IDs to clients", challenges.Count);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
logger.LogError("Failed to migrate device IDs to clients");
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper methods for Base64Url encoding/decoding
|
// Helper methods for Base64Url encoding/decoding
|
||||||
private static string Base64UrlEncode(byte[] data)
|
private static string Base64UrlEncode(byte[] data)
|
||||||
{
|
{
|
||||||
|
@@ -30,6 +30,7 @@ public class AuthServiceGrpc(
|
|||||||
session = await db.AuthSessions
|
session = await db.AuthSessions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(e => e.Challenge)
|
.Include(e => e.Challenge)
|
||||||
|
.ThenInclude(e => e.Client)
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
.ThenInclude(e => e.Profile)
|
.ThenInclude(e => e.Profile)
|
||||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
|
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
@@ -42,7 +43,7 @@ public enum ChallengeType
|
|||||||
Oidc // Trying to connect other platforms
|
Oidc // Trying to connect other platforms
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ChallengePlatform
|
public enum ClientPlatform
|
||||||
{
|
{
|
||||||
Unidentified,
|
Unidentified,
|
||||||
Web,
|
Web,
|
||||||
@@ -60,7 +61,6 @@ public class AuthChallenge : ModelBase
|
|||||||
public int StepRemain { get; set; }
|
public int StepRemain { get; set; }
|
||||||
public int StepTotal { get; set; }
|
public int StepTotal { get; set; }
|
||||||
public int FailedAttempts { get; set; }
|
public int FailedAttempts { get; set; }
|
||||||
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
|
|
||||||
public ChallengeType Type { get; set; } = ChallengeType.Login;
|
public ChallengeType Type { get; set; } = ChallengeType.Login;
|
||||||
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
|
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
|
||||||
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
|
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
|
||||||
@@ -68,12 +68,13 @@ public class AuthChallenge : ModelBase
|
|||||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
[MaxLength(1024)] public string? Nonce { get; set; }
|
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||||
|
[MaxLength(1024)] public string? DeviceId { get; set; } = string.Empty;
|
||||||
public Point? Location { get; set; }
|
public Point? Location { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
public Guid DeviceId { get; set; }
|
public Guid? ClientId { get; set; }
|
||||||
public AuthDevice Device { get; set; } = null!;
|
public AuthClient? Client { get; set; } = null!;
|
||||||
|
|
||||||
public AuthChallenge Normalize()
|
public AuthChallenge Normalize()
|
||||||
{
|
{
|
||||||
@@ -88,15 +89,44 @@ public class AuthChallenge : ModelBase
|
|||||||
StepRemain = StepRemain,
|
StepRemain = StepRemain,
|
||||||
StepTotal = StepTotal,
|
StepTotal = StepTotal,
|
||||||
FailedAttempts = FailedAttempts,
|
FailedAttempts = FailedAttempts,
|
||||||
Platform = (Shared.Proto.ChallengePlatform)Platform,
|
|
||||||
Type = (Shared.Proto.ChallengeType)Type,
|
Type = (Shared.Proto.ChallengeType)Type,
|
||||||
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
|
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
|
||||||
Audiences = { Audiences },
|
Audiences = { Audiences },
|
||||||
Scopes = { Scopes },
|
Scopes = { Scopes },
|
||||||
IpAddress = IpAddress,
|
IpAddress = IpAddress,
|
||||||
UserAgent = UserAgent,
|
UserAgent = UserAgent,
|
||||||
DeviceId = DeviceId.ToString(),
|
DeviceId = Client!.DeviceId,
|
||||||
Nonce = Nonce,
|
Nonce = Nonce,
|
||||||
AccountId = AccountId.ToString()
|
AccountId = AccountId.ToString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AuthClient : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public ClientPlatform Platform { get; set; } = ClientPlatform.Unidentified;
|
||||||
|
[MaxLength(1024)] public string DeviceName { get; set; } = string.Empty;
|
||||||
|
[MaxLength(1024)] public string? DeviceLabel { get; set; }
|
||||||
|
[MaxLength(1024)] public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthClientWithChallenge : AuthClient
|
||||||
|
{
|
||||||
|
public List<AuthChallenge> Challenges { get; set; } = [];
|
||||||
|
|
||||||
|
public static AuthClientWithChallenge FromClient(AuthClient client)
|
||||||
|
{
|
||||||
|
return new AuthClientWithChallenge
|
||||||
|
{
|
||||||
|
Id = client.Id,
|
||||||
|
Platform = client.Platform,
|
||||||
|
DeviceName = client.DeviceName,
|
||||||
|
DeviceLabel = client.DeviceLabel,
|
||||||
|
DeviceId = client.DeviceId,
|
||||||
|
AccountId = client.AccountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -19,8 +19,7 @@ public class OidcProviderController(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
OidcProviderService oidcService,
|
OidcProviderService oidcService,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<OidcProviderOptions> options,
|
IOptions<OidcProviderOptions> options
|
||||||
ILogger<OidcProviderController> logger
|
|
||||||
)
|
)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
@@ -36,74 +35,74 @@ public class OidcProviderController(
|
|||||||
case "authorization_code" when request.Code == null:
|
case "authorization_code" when request.Code == null:
|
||||||
return BadRequest("Authorization code is required");
|
return BadRequest("Authorization code is required");
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
{
|
{
|
||||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||||
if (client == null ||
|
if (client == null ||
|
||||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||||
return BadRequest(new ErrorResponse
|
return BadRequest(new ErrorResponse
|
||||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
|
||||||
clientId: request.ClientId.Value,
|
|
||||||
authorizationCode: request.Code!,
|
|
||||||
redirectUri: request.RedirectUri,
|
|
||||||
codeVerifier: request.CodeVerifier
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(tokenResponse);
|
|
||||||
}
|
|
||||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
|
||||||
return BadRequest(new ErrorResponse
|
|
||||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
|
||||||
case "refresh_token":
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Decode the base64 refresh token to get the session ID
|
|
||||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
|
||||||
var sessionId = new Guid(sessionIdBytes);
|
|
||||||
|
|
||||||
// Find the session and related data
|
|
||||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
|
||||||
if (session?.AppId is null || session.ExpiredAt < now)
|
|
||||||
{
|
|
||||||
return BadRequest(new ErrorResponse
|
|
||||||
{
|
|
||||||
Error = "invalid_grant",
|
|
||||||
ErrorDescription = "Invalid or expired refresh token"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the client
|
|
||||||
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
|
||||||
if (client == null)
|
|
||||||
{
|
|
||||||
return BadRequest(new ErrorResponse
|
|
||||||
{
|
|
||||||
Error = "invalid_client",
|
|
||||||
ErrorDescription = "Client not found"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new tokens
|
|
||||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
clientId: session.AppId!.Value,
|
clientId: request.ClientId.Value,
|
||||||
sessionId: session.Id
|
authorizationCode: request.Code!,
|
||||||
|
redirectUri: request.RedirectUri,
|
||||||
|
codeVerifier: request.CodeVerifier
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(tokenResponse);
|
return Ok(tokenResponse);
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||||
|
case "refresh_token":
|
||||||
{
|
{
|
||||||
return BadRequest(new ErrorResponse
|
try
|
||||||
{
|
{
|
||||||
Error = "invalid_grant",
|
// Decode the base64 refresh token to get the session ID
|
||||||
ErrorDescription = "Invalid refresh token format"
|
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||||
});
|
var sessionId = new Guid(sessionIdBytes);
|
||||||
|
|
||||||
|
// Find the session and related data
|
||||||
|
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (session?.AppId is null || session.ExpiredAt < now)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid or expired refresh token"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the client
|
||||||
|
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_client",
|
||||||
|
ErrorDescription = "Client not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: session.AppId!.Value,
|
||||||
|
sessionId: session.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid refresh token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
}
|
}
|
||||||
@@ -238,4 +237,4 @@ public class TokenRequest
|
|||||||
[JsonPropertyName("code_verifier")]
|
[JsonPropertyName("code_verifier")]
|
||||||
[FromForm(Name = "code_verifier")]
|
[FromForm(Name = "code_verifier")]
|
||||||
public string? CodeVerifier { get; set; }
|
public string? CodeVerifier { get; set; }
|
||||||
}
|
}
|
||||||
|
@@ -14,6 +14,7 @@ public class AppleMobileConnectRequest
|
|||||||
|
|
||||||
public class AppleMobileSignInRequest : AppleMobileConnectRequest
|
public class AppleMobileSignInRequest : AppleMobileConnectRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[Required] [MaxLength(512)]
|
||||||
public required string DeviceId { get; set; }
|
public required string DeviceId { get; set; }
|
||||||
|
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||||
}
|
}
|
||||||
|
@@ -96,7 +96,8 @@ public class OidcController(
|
|||||||
userInfo,
|
userInfo,
|
||||||
account,
|
account,
|
||||||
HttpContext,
|
HttpContext,
|
||||||
request.DeviceId
|
request.DeviceId,
|
||||||
|
request.DeviceName
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(challenge);
|
return Ok(challenge);
|
||||||
|
@@ -191,7 +191,8 @@ public abstract class OidcService(
|
|||||||
OidcUserInfo userInfo,
|
OidcUserInfo userInfo,
|
||||||
Account.Account account,
|
Account.Account account,
|
||||||
HttpContext request,
|
HttpContext request,
|
||||||
string deviceId
|
string deviceId,
|
||||||
|
string? deviceName = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Create or update the account connection
|
// Create or update the account connection
|
||||||
@@ -217,17 +218,16 @@ public abstract class OidcService(
|
|||||||
|
|
||||||
// Create a challenge that's already completed
|
// Create a challenge that's already completed
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId);
|
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios);
|
||||||
var challenge = new AuthChallenge
|
var challenge = new AuthChallenge
|
||||||
{
|
{
|
||||||
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
||||||
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
||||||
Type = ChallengeType.Oidc,
|
Type = ChallengeType.Oidc,
|
||||||
Platform = ChallengePlatform.Unidentified,
|
|
||||||
Audiences = [ProviderName],
|
Audiences = [ProviderName],
|
||||||
Scopes = ["*"],
|
Scopes = ["*"],
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
DeviceId = device.Id,
|
ClientId = device.Id,
|
||||||
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
|
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
|
||||||
UserAgent = request.Request.Headers.UserAgent,
|
UserAgent = request.Request.Headers.UserAgent,
|
||||||
};
|
};
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
using DysonNetwork.Pass.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
|
using NodaTime;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Pass.Handlers;
|
namespace DysonNetwork.Pass.Handlers;
|
||||||
@@ -12,7 +13,12 @@ public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHan
|
|||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = serviceProvider.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
|
||||||
await db.BulkInsertAsync(items, config => config.ConflictOption = ConflictOption.Ignore);
|
await db.BulkInsertAsync(items.Select(x =>
|
||||||
|
{
|
||||||
|
x.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
x.UpdatedAt = x.CreatedAt;
|
||||||
|
return x;
|
||||||
|
}), config => config.ConflictOption = ConflictOption.Ignore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,4 +28,4 @@ public class ActionLogFlushJob(FlushBufferService fbs, ActionLogFlushHandler hdl
|
|||||||
{
|
{
|
||||||
await fbs.FlushAsync(hdl);
|
await fbs.FlushAsync(hdl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1830
DysonNetwork.Pass/Migrations/20250813072436_AddAuthorizeDevice.Designer.cs
generated
Normal file
1830
DysonNetwork.Pass/Migrations/20250813072436_AddAuthorizeDevice.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuthorizeDevice : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "device_id",
|
||||||
|
table: "auth_challenges",
|
||||||
|
type: "character varying(1024)",
|
||||||
|
maxLength: 1024,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(256)",
|
||||||
|
oldMaxLength: 256,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "client_id",
|
||||||
|
table: "auth_challenges",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "auth_clients",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
device_name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
device_label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
device_id = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
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_auth_clients", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_auth_clients_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_challenges_client_id",
|
||||||
|
table: "auth_challenges",
|
||||||
|
column: "client_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_clients_account_id",
|
||||||
|
table: "auth_clients",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_clients_device_id",
|
||||||
|
table: "auth_clients",
|
||||||
|
column: "device_id",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_auth_challenges_auth_clients_client_id",
|
||||||
|
table: "auth_challenges",
|
||||||
|
column: "client_id",
|
||||||
|
principalTable: "auth_clients",
|
||||||
|
principalColumn: "id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_auth_challenges_auth_clients_client_id",
|
||||||
|
table: "auth_challenges");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_clients");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_auth_challenges_client_id",
|
||||||
|
table: "auth_challenges");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "client_id",
|
||||||
|
table: "auth_challenges");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "device_id",
|
||||||
|
table: "auth_challenges",
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(1024)",
|
||||||
|
oldMaxLength: 1024,
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1830
DysonNetwork.Pass/Migrations/20250813121421_AddAuthDevicePlatform.Designer.cs
generated
Normal file
1830
DysonNetwork.Pass/Migrations/20250813121421_AddAuthDevicePlatform.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuthDevicePlatform : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "platform",
|
||||||
|
table: "auth_challenges");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "platform",
|
||||||
|
table: "auth_clients",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "platform",
|
||||||
|
table: "auth_clients");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "platform",
|
||||||
|
table: "auth_challenges",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1826
DysonNetwork.Pass/Migrations/20250815041723_RemoveAuthClientIndex.Designer.cs
generated
Normal file
1826
DysonNetwork.Pass/Migrations/20250815041723_RemoveAuthClientIndex.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveAuthClientIndex : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_auth_clients_device_id",
|
||||||
|
table: "auth_clients");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_clients_device_id",
|
||||||
|
table: "auth_clients",
|
||||||
|
column: "device_id",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -435,7 +435,7 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_seen_at");
|
.HasColumnName("last_seen_at");
|
||||||
|
|
||||||
b.Property<Dictionary<string, string>>("Links")
|
b.Property<List<ProfileLink>>("Links")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
|
||||||
@@ -817,6 +817,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("blacklist_factors");
|
.HasColumnName("blacklist_factors");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ClientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("client_id");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
@@ -826,8 +830,8 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnName("deleted_at");
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
b.Property<string>("DeviceId")
|
b.Property<string>("DeviceId")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("device_id");
|
.HasColumnName("device_id");
|
||||||
|
|
||||||
b.Property<Instant?>("ExpiredAt")
|
b.Property<Instant?>("ExpiredAt")
|
||||||
@@ -852,10 +856,6 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("nonce");
|
.HasColumnName("nonce");
|
||||||
|
|
||||||
b.Property<int>("Platform")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("platform");
|
|
||||||
|
|
||||||
b.Property<List<string>>("Scopes")
|
b.Property<List<string>>("Scopes")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
@@ -888,9 +888,65 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
b.HasIndex("AccountId")
|
b.HasIndex("AccountId")
|
||||||
.HasDatabaseName("ix_auth_challenges_account_id");
|
.HasDatabaseName("ix_auth_challenges_account_id");
|
||||||
|
|
||||||
|
b.HasIndex("ClientId")
|
||||||
|
.HasDatabaseName("ix_auth_challenges_client_id");
|
||||||
|
|
||||||
b.ToTable("auth_challenges", (string)null);
|
b.ToTable("auth_challenges", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthClient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("device_id");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceLabel")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("device_label");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("device_name");
|
||||||
|
|
||||||
|
b.Property<int>("Platform")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("platform");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_auth_clients");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_auth_clients_account_id");
|
||||||
|
|
||||||
|
b.ToTable("auth_clients", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthSession", b =>
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthSession", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1586,6 +1642,25 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_auth_challenges_accounts_account_id");
|
.HasConstraintName("fk_auth_challenges_accounts_account_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Pass.Auth.AuthClient", "Client")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ClientId")
|
||||||
|
.HasConstraintName("fk_auth_challenges_auth_clients_client_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
|
||||||
|
b.Navigation("Client");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthClient", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_auth_clients_accounts_account_id");
|
||||||
|
|
||||||
b.Navigation("Account");
|
b.Navigation("Account");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
using DysonNetwork.Pass;
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Pass.Pages.Data;
|
using DysonNetwork.Pass.Pages.Data;
|
||||||
using DysonNetwork.Pass.Startup;
|
using DysonNetwork.Pass.Startup;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
@@ -56,4 +57,4 @@ app.MapPages(Path.Combine(builder.Environment.WebRootPath, "dist", "index.html")
|
|||||||
// Configure gRPC
|
// Configure gRPC
|
||||||
app.ConfigureGrpcServices();
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
@@ -16,7 +16,7 @@ public class AppDatabase(
|
|||||||
) : DbContext(options)
|
) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<Notification.Notification> Notifications { get; set; } = null!;
|
public DbSet<Notification.Notification> Notifications { get; set; } = null!;
|
||||||
public DbSet<PushSubscription> PushSubscriptions { get; set; }
|
public DbSet<PushSubscription> PushSubscriptions { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -175,4 +175,4 @@ public static class OptionalQueryExtensions
|
|||||||
{
|
{
|
||||||
return condition ? transform(source) : source;
|
return condition ? transform(source) : source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,6 @@ using Swashbuckle.AspNetCore.Annotations;
|
|||||||
namespace DysonNetwork.Pusher.Connection;
|
namespace DysonNetwork.Pusher.Connection;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/ws")]
|
|
||||||
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
|
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[Route("/ws")]
|
[Route("/ws")]
|
||||||
@@ -45,7 +44,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
Type = "error.dupe",
|
Type = "error.dupe",
|
||||||
ErrorMessage = "Too many connections from the same device and account."
|
ErrorMessage = "Too many connections from the same device and account."
|
||||||
}.ToBytes()),
|
}.ToBytes()),
|
||||||
WebSocketMessageType.Close,
|
WebSocketMessageType.Binary,
|
||||||
true,
|
true,
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
@@ -57,7 +56,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogDebug(
|
||||||
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
|
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -66,12 +65,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"WebSocket Error: {ex.Message}");
|
logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
ws.Disconnect(connectionKey);
|
ws.Disconnect(connectionKey);
|
||||||
logger.LogInformation(
|
logger.LogDebug(
|
||||||
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}"
|
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,4 +113,4 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Notification;
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using AccountService = DysonNetwork.Shared.Proto.AccountService;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Notification;
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
|
||||||
@@ -13,8 +12,8 @@ namespace DysonNetwork.Pusher.Notification;
|
|||||||
[Route("/api/notifications")]
|
[Route("/api/notifications")]
|
||||||
public class NotificationController(
|
public class NotificationController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
PushService nty,
|
PushService nty
|
||||||
AccountService.AccountServiceClient accounts) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("count")]
|
[HttpGet("count")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -59,6 +58,18 @@ public class NotificationController(
|
|||||||
return Ok(notifications);
|
return Ok(notifications);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("all/read")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> MarkAllNotificationsViewed()
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
if (currentUserValue is not Account currentUser) return Unauthorized();
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
await nty.MarkAllNotificationsViewed(accountId);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
public class PushNotificationSubscribeRequest
|
public class PushNotificationSubscribeRequest
|
||||||
{
|
{
|
||||||
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
|
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
|
||||||
@@ -81,7 +92,7 @@ public class NotificationController(
|
|||||||
|
|
||||||
var result =
|
var result =
|
||||||
await nty.SubscribeDevice(
|
await nty.SubscribeDevice(
|
||||||
currentSession.Challenge.DeviceId!,
|
currentSession.Challenge.DeviceId,
|
||||||
request.DeviceToken,
|
request.DeviceToken,
|
||||||
request.Provider,
|
request.Provider,
|
||||||
currentUser
|
currentUser
|
||||||
@@ -112,11 +123,11 @@ public class NotificationController(
|
|||||||
|
|
||||||
public class NotificationRequest
|
public class NotificationRequest
|
||||||
{
|
{
|
||||||
[Required] [MaxLength(1024)] public string Topic { get; set; } = null!;
|
[Required][MaxLength(1024)] public string Topic { get; set; } = null!;
|
||||||
[Required] [MaxLength(1024)] public string Title { get; set; } = null!;
|
[Required][MaxLength(1024)] public string Title { get; set; } = null!;
|
||||||
[MaxLength(2048)] public string? Subtitle { get; set; }
|
[MaxLength(2048)] public string? Subtitle { get; set; }
|
||||||
[Required] [MaxLength(4096)] public string Content { get; set; } = null!;
|
[Required][MaxLength(4096)] public string Content { get; set; } = null!;
|
||||||
public Dictionary<string, object>? Meta { get; set; }
|
public Dictionary<string, object?>? Meta { get; set; }
|
||||||
public int Priority { get; set; } = 10;
|
public int Priority { get; set; } = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +153,11 @@ public class NotificationController(
|
|||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
Subtitle = request.Subtitle,
|
Subtitle = request.Subtitle,
|
||||||
Content = request.Content,
|
Content = request.Content,
|
||||||
Meta = request.Meta,
|
Meta = request.Meta ?? [],
|
||||||
},
|
},
|
||||||
request.AccountId,
|
request.AccountId,
|
||||||
save
|
save
|
||||||
);
|
);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
27
DysonNetwork.Pusher/Notification/NotificationFlushHandler.cs
Normal file
27
DysonNetwork.Pusher/Notification/NotificationFlushHandler.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using EFCore.BulkExtensions;
|
||||||
|
using NodaTime;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
|
||||||
|
public class NotificationFlushHandler(AppDatabase db) : IFlushHandler<Notification>
|
||||||
|
{
|
||||||
|
public async Task FlushAsync(IReadOnlyList<Notification> items)
|
||||||
|
{
|
||||||
|
await db.BulkInsertAsync(items.Select(x =>
|
||||||
|
{
|
||||||
|
x.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
x.UpdatedAt = x.CreatedAt;
|
||||||
|
return x;
|
||||||
|
}), config => config.ConflictOption = ConflictOption.Ignore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationFlushJob(FlushBufferService fbs, NotificationFlushHandler hdl) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
await fbs.FlushAsync(hdl);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,25 +1,33 @@
|
|||||||
using CorePush.Apple;
|
using CorePush.Apple;
|
||||||
using CorePush.Firebase;
|
using CorePush.Firebase;
|
||||||
using DysonNetwork.Pusher.Connection;
|
using DysonNetwork.Pusher.Connection;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using EFCore.BulkExtensions;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Notification;
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
|
||||||
public class PushService
|
public class PushService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly AppDatabase _db;
|
private readonly AppDatabase _db;
|
||||||
|
private readonly FlushBufferService _fbs;
|
||||||
private readonly WebSocketService _ws;
|
private readonly WebSocketService _ws;
|
||||||
private readonly ILogger<PushService> _logger;
|
private readonly ILogger<PushService> _logger;
|
||||||
private readonly FirebaseSender? _fcm;
|
private readonly FirebaseSender? _fcm;
|
||||||
private readonly ApnSender? _apns;
|
private readonly ApnSender? _apns;
|
||||||
private readonly string? _apnsTopic;
|
private readonly string? _apnsTopic;
|
||||||
|
|
||||||
|
private readonly Channel<PushWorkItem> _channel;
|
||||||
|
private readonly int _maxConcurrency;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly List<Task> _workers = new();
|
||||||
|
|
||||||
public PushService(
|
public PushService(
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
|
FlushBufferService fbs,
|
||||||
WebSocketService ws,
|
WebSocketService ws,
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
ILogger<PushService> logger
|
ILogger<PushService> logger
|
||||||
@@ -50,8 +58,48 @@ public class PushService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_fbs = fbs;
|
||||||
_ws = ws;
|
_ws = ws;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
|
// --- Concurrency & channel config ---
|
||||||
|
// Defaults: 8 workers, bounded capacity 2000 items.
|
||||||
|
_maxConcurrency = Math.Max(1, cfgSection.GetValue<int?>("MaxConcurrency") ?? 8);
|
||||||
|
var capacity = Math.Max(1, cfgSection.GetValue<int?>("ChannelCapacity") ?? 2000);
|
||||||
|
|
||||||
|
_channel = Channel.CreateBounded<PushWorkItem>(new BoundedChannelOptions(capacity)
|
||||||
|
{
|
||||||
|
SingleWriter = false,
|
||||||
|
SingleReader = false,
|
||||||
|
FullMode = BoundedChannelFullMode.Wait, // apply backpressure instead of dropping
|
||||||
|
AllowSynchronousContinuations = false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start background consumers
|
||||||
|
for (int i = 0; i < _maxConcurrency; i++)
|
||||||
|
{
|
||||||
|
_workers.Add(Task.Run(() => WorkerLoop(_cts.Token)));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("PushService initialized with {Workers} workers and capacity {Capacity}", _maxConcurrency, capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_channel.Writer.TryComplete();
|
||||||
|
_cts.Cancel();
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Task.WhenAll(_workers).Wait(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
|
||||||
|
_cts.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UnsubscribeDevice(string deviceId)
|
public async Task UnsubscribeDevice(string deviceId)
|
||||||
@@ -79,7 +127,6 @@ public class PushService
|
|||||||
|
|
||||||
if (existingSubscription != null)
|
if (existingSubscription != null)
|
||||||
{
|
{
|
||||||
// Update existing subscription
|
|
||||||
existingSubscription.DeviceId = deviceId;
|
existingSubscription.DeviceId = deviceId;
|
||||||
existingSubscription.DeviceToken = deviceToken;
|
existingSubscription.DeviceToken = deviceToken;
|
||||||
existingSubscription.Provider = provider;
|
existingSubscription.Provider = provider;
|
||||||
@@ -90,7 +137,6 @@ public class PushService
|
|||||||
return existingSubscription;
|
return existingSubscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new subscription
|
|
||||||
var subscription = new PushSubscription
|
var subscription = new PushSubscription
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
@@ -112,11 +158,12 @@ public class PushService
|
|||||||
string? title = null,
|
string? title = null,
|
||||||
string? subtitle = null,
|
string? subtitle = null,
|
||||||
string? content = null,
|
string? content = null,
|
||||||
Dictionary<string, object?> meta = null,
|
Dictionary<string, object?>? meta = null,
|
||||||
string? actionUri = null,
|
string? actionUri = null,
|
||||||
bool isSilent = false,
|
bool isSilent = false,
|
||||||
bool save = true)
|
bool save = true)
|
||||||
{
|
{
|
||||||
|
meta ??= [];
|
||||||
if (title is null && subtitle is null && content is null)
|
if (title is null && subtitle is null && content is null)
|
||||||
throw new ArgumentException("Unable to send notification that completely empty.");
|
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||||
|
|
||||||
@@ -134,12 +181,10 @@ public class PushService
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (save)
|
if (save)
|
||||||
{
|
_fbs.Enqueue(notification);
|
||||||
_db.Add(notification);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSilent) _ = DeliveryNotification(notification);
|
if (!isSilent)
|
||||||
|
await DeliveryNotification(notification); // returns quickly (does NOT wait for APNS/FCM)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeliveryNotification(Notification notification)
|
private async Task DeliveryNotification(Notification notification)
|
||||||
@@ -151,12 +196,20 @@ public class PushService
|
|||||||
notification.Meta
|
notification.Meta
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pushing the notification
|
// WS send: still immediate (fire-and-forget from caller perspective)
|
||||||
|
_ws.SendPacketToAccount(notification.AccountId.ToString(), new Connection.WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = "notifications.new",
|
||||||
|
Data = notification
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query subscribers and enqueue push work (non-blocking to the HTTP request)
|
||||||
var subscribers = await _db.PushSubscriptions
|
var subscribers = await _db.PushSubscriptions
|
||||||
.Where(s => s.AccountId == notification.AccountId)
|
.Where(s => s.AccountId == notification.AccountId)
|
||||||
|
.AsNoTracking()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
await _PushNotification(notification, subscribers);
|
await EnqueuePushWork(notification, subscribers);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||||
@@ -167,15 +220,22 @@ public class PushService
|
|||||||
|
|
||||||
await _db.Notifications
|
await _db.Notifications
|
||||||
.Where(n => id.Contains(n.Id))
|
.Where(n => id.Contains(n.Id))
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
|
||||||
);
|
}
|
||||||
|
|
||||||
|
public async Task MarkAllNotificationsViewed(Guid accountId)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
await _db.Notifications
|
||||||
|
.Where(n => n.AccountId == accountId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false)
|
public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false)
|
||||||
{
|
{
|
||||||
if (save)
|
if (save)
|
||||||
{
|
{
|
||||||
var notifications = accounts.Select(x =>
|
accounts.ForEach(x =>
|
||||||
{
|
{
|
||||||
var newNotification = new Notification
|
var newNotification = new Notification
|
||||||
{
|
{
|
||||||
@@ -187,11 +247,10 @@ public class PushService
|
|||||||
Priority = notification.Priority,
|
Priority = notification.Priority,
|
||||||
AccountId = x
|
AccountId = x
|
||||||
};
|
};
|
||||||
return newNotification;
|
_fbs.Enqueue(newNotification);
|
||||||
}).ToList();
|
});
|
||||||
await _db.BulkInsertAsync(notifications);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
|
"Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
|
||||||
notification.Topic,
|
notification.Topic,
|
||||||
@@ -199,24 +258,66 @@ public class PushService
|
|||||||
notification.Meta
|
notification.Meta
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// WS first
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
notification.AccountId = account; // keep original behavior
|
||||||
|
_ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = "notifications.new",
|
||||||
|
Data = notification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all subscribers once and enqueue to workers
|
||||||
var subscribers = await _db.PushSubscriptions
|
var subscribers = await _db.PushSubscriptions
|
||||||
.Where(s => accounts.Contains(s.AccountId))
|
.Where(s => accounts.Contains(s.AccountId))
|
||||||
|
.AsNoTracking()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
await _PushNotification(notification, subscribers);
|
|
||||||
|
await EnqueuePushWork(notification, subscribers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task _PushNotification(
|
private async Task EnqueuePushWork(Notification notification, IEnumerable<PushSubscription> subscriptions)
|
||||||
Notification notification,
|
|
||||||
IEnumerable<PushSubscription> subscriptions
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var tasks = subscriptions
|
foreach (var sub in subscriptions)
|
||||||
.Select(subscription => _PushSingleNotification(notification, subscription))
|
{
|
||||||
.ToList();
|
// Use the current notification reference (no mutation of content after this point).
|
||||||
|
var item = new PushWorkItem(notification, sub);
|
||||||
|
|
||||||
await Task.WhenAll(tasks);
|
// Respect backpressure if channel is full.
|
||||||
|
await _channel.Writer.WriteAsync(item, _cts.Token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task WorkerLoop(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var item in _channel.Reader.ReadAllAsync(ct))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _PushSingleNotification(item.Notification, item.Subscription);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Worker handled exception for notification #{Id}", item.Notification.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// normal shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct PushWorkItem(Notification Notification, PushSubscription Subscription);
|
||||||
|
|
||||||
private async Task _PushSingleNotification(Notification notification, PushSubscription subscription)
|
private async Task _PushSingleNotification(Notification notification, PushSubscription subscription)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -235,10 +336,11 @@ public class PushService
|
|||||||
{
|
{
|
||||||
body = string.Join("\n",
|
body = string.Join("\n",
|
||||||
notification.Subtitle ?? string.Empty,
|
notification.Subtitle ?? string.Empty,
|
||||||
notification.Content ?? string.Empty).Trim();
|
notification.Content ?? string.Empty
|
||||||
|
).Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
await _fcm.SendAsync(new Dictionary<string, object>
|
var fcmResult = await _fcm.SendAsync(new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["message"] = new Dictionary<string, object>
|
["message"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -248,14 +350,18 @@ public class PushService
|
|||||||
["title"] = notification.Title ?? string.Empty,
|
["title"] = notification.Title ?? string.Empty,
|
||||||
["body"] = body
|
["body"] = body
|
||||||
},
|
},
|
||||||
["data"] = new Dictionary<string, object>
|
// You can re-enable data payloads if needed.
|
||||||
{
|
// ["data"] = new Dictionary<string, object>
|
||||||
["id"] = notification.Id,
|
// {
|
||||||
["topic"] = notification.Topic,
|
// ["Id"] = notification.Id,
|
||||||
["meta"] = notification.Meta
|
// ["Topic"] = notification.Topic,
|
||||||
}
|
// ["Meta"] = notification.Meta
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (fcmResult.Error != null)
|
||||||
|
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case PushProvider.Apple:
|
case PushProvider.Apple:
|
||||||
@@ -283,13 +389,16 @@ public class PushService
|
|||||||
["meta"] = notification.Meta
|
["meta"] = notification.Meta
|
||||||
};
|
};
|
||||||
|
|
||||||
await _apns.SendAsync(
|
var apnResult = await _apns.SendAsync(
|
||||||
payload,
|
payload,
|
||||||
deviceToken: subscription.DeviceToken,
|
deviceToken: subscription.DeviceToken,
|
||||||
apnsId: notification.Id.ToString(),
|
apnsId: notification.Id.ToString(),
|
||||||
apnsPriority: notification.Priority,
|
apnsPriority: notification.Priority,
|
||||||
apnPushType: ApnPushType.Alert
|
apnPushType: ApnPushType.Alert
|
||||||
);
|
);
|
||||||
|
if (apnResult.Error != null)
|
||||||
|
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -300,10 +409,10 @@ public class PushService
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex,
|
_logger.LogError(ex,
|
||||||
$"Failed to push notification #{notification.Id} to device {subscription.DeviceId}. {ex.Message}");
|
$"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);
|
// Swallow here to keep worker alive; upstream is fire-and-forget.
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId}");
|
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -24,7 +24,6 @@ builder.Services.AddAppFlushHandlers();
|
|||||||
|
|
||||||
// Add business services
|
// Add business services
|
||||||
builder.Services.AddAppBusinessServices();
|
builder.Services.AddAppBusinessServices();
|
||||||
builder.Services.AddPushServices(builder.Configuration);
|
|
||||||
|
|
||||||
// Add scheduled jobs
|
// Add scheduled jobs
|
||||||
builder.Services.AddAppScheduledJobs();
|
builder.Services.AddAppScheduledJobs();
|
||||||
@@ -44,4 +43,4 @@ app.ConfigureAppMiddleware(builder.Configuration);
|
|||||||
// Configure gRPC
|
// Configure gRPC
|
||||||
app.ConfigureGrpcServices();
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
@@ -141,4 +141,4 @@ public class PusherServiceGrpc(
|
|||||||
|
|
||||||
return Task.FromResult(new GetWebsocketConnectionStatusResponse { IsConnected = isConnected });
|
return Task.FromResult(new GetWebsocketConnectionStatusResponse { IsConnected = isConnected });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
using DysonNetwork.Pusher.Notification;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Pusher.Startup;
|
namespace DysonNetwork.Pusher.Startup;
|
||||||
@@ -14,6 +15,13 @@ public static class ScheduledJobsConfiguration
|
|||||||
.ForJob(appDatabaseRecyclingJob)
|
.ForJob(appDatabaseRecyclingJob)
|
||||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||||
.WithCronSchedule("0 0 0 * * ?"));
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
|
|
||||||
|
var notificationFlushJob = new JobKey("NotificationFlush");
|
||||||
|
q.AddJob<NotificationFlushJob>(opts => opts.WithIdentity(notificationFlushJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(notificationFlushJob)
|
||||||
|
.WithIdentity("NotificationFlushTrigger")
|
||||||
|
.WithSimpleSchedule(a => a.WithIntervalInSeconds(60).RepeatForever()));
|
||||||
});
|
});
|
||||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
@@ -38,7 +38,7 @@ public static class ServiceCollectionExtensions
|
|||||||
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
|
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
|
||||||
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
|
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register gRPC reflection for service discovery
|
// Register gRPC reflection for service discovery
|
||||||
services.AddGrpc();
|
services.AddGrpc();
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ public static class ServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<FlushBufferService>();
|
services.AddSingleton<FlushBufferService>();
|
||||||
|
services.AddScoped<NotificationFlushHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -139,13 +140,4 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@@ -30,7 +30,6 @@ message AuthChallenge {
|
|||||||
int32 step_remain = 3;
|
int32 step_remain = 3;
|
||||||
int32 step_total = 4;
|
int32 step_total = 4;
|
||||||
int32 failed_attempts = 5;
|
int32 failed_attempts = 5;
|
||||||
ChallengePlatform platform = 6;
|
|
||||||
ChallengeType type = 7;
|
ChallengeType type = 7;
|
||||||
repeated string blacklist_factors = 8;
|
repeated string blacklist_factors = 8;
|
||||||
repeated string audiences = 9;
|
repeated string audiences = 9;
|
||||||
|
@@ -24,37 +24,37 @@ public class AppDatabase(
|
|||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
) : DbContext(options)
|
) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<Publisher.Publisher> Publishers { get; set; }
|
public DbSet<Publisher.Publisher> Publishers { get; set; } = null!;
|
||||||
public DbSet<PublisherMember> PublisherMembers { get; set; }
|
public DbSet<PublisherMember> PublisherMembers { get; set; } = null!;
|
||||||
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }
|
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; } = null!;
|
||||||
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
|
public DbSet<PublisherFeature> PublisherFeatures { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Post.Post> Posts { get; set; }
|
public DbSet<Post.Post> Posts { get; set; } = null!;
|
||||||
public DbSet<PostReaction> PostReactions { get; set; }
|
public DbSet<PostReaction> PostReactions { get; set; } = null!;
|
||||||
public DbSet<PostTag> PostTags { get; set; }
|
public DbSet<PostTag> PostTags { get; set; } = null!;
|
||||||
public DbSet<PostCategory> PostCategories { get; set; }
|
public DbSet<PostCategory> PostCategories { get; set; } = null!;
|
||||||
public DbSet<PostCollection> PostCollections { get; set; }
|
public DbSet<PostCollection> PostCollections { get; set; } = null!;
|
||||||
public DbSet<PostFeaturedRecord> PostFeaturedRecords { get; set; }
|
public DbSet<PostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Poll.Poll> Polls { get; set; }
|
public DbSet<Poll.Poll> Polls { get; set; } = null!;
|
||||||
public DbSet<Poll.PollQuestion> PollQuestions { get; set; }
|
public DbSet<Poll.PollQuestion> PollQuestions { get; set; } = null!;
|
||||||
public DbSet<Poll.PollAnswer> PollAnswers { get; set; }
|
public DbSet<Poll.PollAnswer> PollAnswers { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Realm.Realm> Realms { get; set; }
|
public DbSet<Realm.Realm> Realms { get; set; } = null!;
|
||||||
public DbSet<RealmMember> RealmMembers { get; set; }
|
public DbSet<RealmMember> RealmMembers { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<ChatRoom> ChatRooms { get; set; }
|
public DbSet<ChatRoom> ChatRooms { get; set; } = null!;
|
||||||
public DbSet<ChatMember> ChatMembers { get; set; }
|
public DbSet<ChatMember> ChatMembers { get; set; } = null!;
|
||||||
public DbSet<Message> ChatMessages { get; set; }
|
public DbSet<Message> ChatMessages { get; set; } = null!;
|
||||||
public DbSet<RealtimeCall> ChatRealtimeCall { get; set; }
|
public DbSet<RealtimeCall> ChatRealtimeCall { get; set; } = null!;
|
||||||
public DbSet<MessageReaction> ChatReactions { get; set; }
|
public DbSet<MessageReaction> ChatReactions { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<Sticker.Sticker> Stickers { get; set; }
|
public DbSet<Sticker.Sticker> Stickers { get; set; } = null!;
|
||||||
public DbSet<StickerPack> StickerPacks { get; set; }
|
public DbSet<StickerPack> StickerPacks { get; set; } = null!;
|
||||||
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; }
|
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!;
|
||||||
|
|
||||||
public DbSet<WebReader.WebArticle> WebArticles { get; set; }
|
public DbSet<WebReader.WebArticle> WebArticles { get; set; } = null!;
|
||||||
public DbSet<WebReader.WebFeed> WebFeeds { get; set; }
|
public DbSet<WebReader.WebFeed> WebFeeds { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
@@ -25,7 +25,7 @@ public class ChatRoom : ModelBase, IIdentifiedResource
|
|||||||
// Outdated fields, for backward compability
|
// Outdated fields, for backward compability
|
||||||
[MaxLength(32)] public string? PictureId { get; set; }
|
[MaxLength(32)] public string? PictureId { get; set; }
|
||||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ public class ChatMember : ModelBase
|
|||||||
public Instant? JoinedAt { get; set; }
|
public Instant? JoinedAt { get; set; }
|
||||||
public Instant? LeaveAt { get; set; }
|
public Instant? LeaveAt { get; set; }
|
||||||
public bool IsBot { get; set; } = false;
|
public bool IsBot { get; set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The break time is the user doesn't receive any message from this member for a while.
|
/// The break time is the user doesn't receive any message from this member for a while.
|
||||||
/// Expect mentioned him or her.
|
/// Expect mentioned him or her.
|
||||||
@@ -115,7 +115,7 @@ public class ChatMemberTransmissionObject : ModelBase
|
|||||||
public Instant? JoinedAt { get; set; }
|
public Instant? JoinedAt { get; set; }
|
||||||
public Instant? LeaveAt { get; set; }
|
public Instant? LeaveAt { get; set; }
|
||||||
public bool IsBot { get; set; } = false;
|
public bool IsBot { get; set; } = false;
|
||||||
|
|
||||||
public Instant? BreakUntil { get; set; }
|
public Instant? BreakUntil { get; set; }
|
||||||
public Instant? TimeoutUntil { get; set; }
|
public Instant? TimeoutUntil { get; set; }
|
||||||
public ChatTimeoutCause? TimeoutCause { get; set; }
|
public ChatTimeoutCause? TimeoutCause { get; set; }
|
||||||
@@ -127,7 +127,7 @@ public class ChatMemberTransmissionObject : ModelBase
|
|||||||
Id = member.Id,
|
Id = member.Id,
|
||||||
ChatRoomId = member.ChatRoomId,
|
ChatRoomId = member.ChatRoomId,
|
||||||
AccountId = member.AccountId,
|
AccountId = member.AccountId,
|
||||||
Account = member.Account,
|
Account = member.Account!,
|
||||||
Nick = member.Nick,
|
Nick = member.Nick,
|
||||||
Role = member.Role,
|
Role = member.Role,
|
||||||
Notify = member.Notify,
|
Notify = member.Notify,
|
||||||
@@ -142,4 +142,4 @@ public class ChatMemberTransmissionObject : ModelBase
|
|||||||
DeletedAt = member.DeletedAt
|
DeletedAt = member.DeletedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -164,7 +164,7 @@ public class ChatRoomController(
|
|||||||
|
|
||||||
public class ChatRoomRequest
|
public class ChatRoomRequest
|
||||||
{
|
{
|
||||||
[Required] [MaxLength(1024)] public string? Name { get; set; }
|
[Required][MaxLength(1024)] public string? Name { get; set; }
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
[MaxLength(32)] public string? PictureId { get; set; }
|
[MaxLength(32)] public string? PictureId { get; set; }
|
||||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||||
@@ -576,7 +576,7 @@ public class ChatRoomController(
|
|||||||
Status = -100
|
Status = -100
|
||||||
});
|
});
|
||||||
|
|
||||||
if (relationship != null && relationship.Relationship.Status == -100)
|
if (relationship?.Relationship != null && relationship.Relationship.Status == -100)
|
||||||
return StatusCode(403, "You cannot invite a user that blocked you.");
|
return StatusCode(403, "You cannot invite a user that blocked you.");
|
||||||
|
|
||||||
var chatRoom = await db.ChatRooms
|
var chatRoom = await db.ChatRooms
|
||||||
@@ -970,7 +970,7 @@ public class ChatRoomController(
|
|||||||
? localizer["ChatInviteDirectBody", sender.Nick]
|
? localizer["ChatInviteDirectBody", sender.Nick]
|
||||||
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
|
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
|
||||||
|
|
||||||
CultureService.SetCultureInfo(member.Account.Language);
|
CultureService.SetCultureInfo(member.Account!.Language);
|
||||||
await pusher.SendPushNotificationToUserAsync(
|
await pusher.SendPushNotificationToUserAsync(
|
||||||
new SendPushNotificationToUserRequest
|
new SendPushNotificationToUserRequest
|
||||||
{
|
{
|
||||||
@@ -985,4 +985,4 @@ public class ChatRoomController(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -254,14 +254,36 @@ public partial class ChatService(
|
|||||||
notification.Body = "Call begun";
|
notification.Body = "Call begun";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments";
|
||||||
notification.Body = !string.IsNullOrEmpty(message.Content)
|
notification.Body = !string.IsNullOrEmpty(message.Content)
|
||||||
? message.Content[..Math.Min(message.Content.Length, 100)]
|
? message.Content[..Math.Min(message.Content.Length, 100)]
|
||||||
: $"<{message.Attachments.Count} attachments>";
|
: $"<{message.Attachments.Count} {attachmentWord}>";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case WebSocketPacketType.MessageUpdate:
|
||||||
|
notification.Body += " (edited)";
|
||||||
|
break;
|
||||||
|
case WebSocketPacketType.MessageDelete:
|
||||||
|
notification.Body = "Deleted a message";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
var request = new PushWebSocketPacketToUsersRequest
|
||||||
|
{
|
||||||
|
Packet = new WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null).Select(a => a!.Id.ToString()));
|
||||||
|
await scopedNty.PushWebSocketPacketToUsersAsync(request);
|
||||||
|
|
||||||
List<Account> accountsToNotify = [];
|
List<Account> accountsToNotify = [];
|
||||||
foreach (
|
foreach (
|
||||||
var member in members
|
var member in members
|
||||||
@@ -279,17 +301,6 @@ public partial class ChatService(
|
|||||||
accountsToNotify.Add(member.Account.ToProtoValue());
|
accountsToNotify.Add(member.Account.ToProtoValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = new PushWebSocketPacketToUsersRequest
|
|
||||||
{
|
|
||||||
Packet = new WebSocketPacket
|
|
||||||
{
|
|
||||||
Type = type,
|
|
||||||
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
request.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString()));
|
|
||||||
await scopedNty.PushWebSocketPacketToUsersAsync(request);
|
|
||||||
|
|
||||||
accountsToNotify = accountsToNotify
|
accountsToNotify = accountsToNotify
|
||||||
.Where(a => a.Id != sender.AccountId.ToString()).ToList();
|
.Where(a => a.Id != sender.AccountId.ToString()).ToList();
|
||||||
|
|
||||||
@@ -383,21 +394,17 @@ public partial class ChatService(
|
|||||||
|
|
||||||
// Get keys of messages to remove (where sender is not found)
|
// Get keys of messages to remove (where sender is not found)
|
||||||
var messagesToRemove = messages
|
var messagesToRemove = messages
|
||||||
.Where(m => messageSenders.All(s => s.Id != m.Value.SenderId))
|
.Where(m => messageSenders.All(s => s.Id != m.Value!.SenderId))
|
||||||
.Select(m => m.Key)
|
.Select(m => m.Key)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Remove messages with no sender
|
// Remove messages with no sender
|
||||||
foreach (var key in messagesToRemove)
|
foreach (var key in messagesToRemove)
|
||||||
{
|
|
||||||
messages.Remove(key);
|
messages.Remove(key);
|
||||||
}
|
|
||||||
|
|
||||||
// Update remaining messages with their senders
|
// Update remaining messages with their senders
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
|
||||||
message.Value!.Sender = messageSenders.First(x => x.Id == message.Value.SenderId);
|
message.Value!.Sender = messageSenders.First(x => x.Id == message.Value.SenderId);
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
@@ -647,4 +654,4 @@ public class SyncResponse
|
|||||||
{
|
{
|
||||||
public List<MessageChange> Changes { get; set; } = [];
|
public List<MessageChange> Changes { get; set; } = [];
|
||||||
public Instant CurrentTimestamp { get; set; }
|
public Instant CurrentTimestamp { get; set; }
|
||||||
}
|
}
|
||||||
|
1944
DysonNetwork.Sphere/Migrations/20250814183405_AddRealmPost.Designer.cs
generated
Normal file
1944
DysonNetwork.Sphere/Migrations/20250814183405_AddRealmPost.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRealmPost : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "realm_id",
|
||||||
|
table: "posts",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_posts_realm_id",
|
||||||
|
table: "posts",
|
||||||
|
column: "realm_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_posts_realms_realm_id",
|
||||||
|
table: "posts",
|
||||||
|
column: "realm_id",
|
||||||
|
principalTable: "realms",
|
||||||
|
principalColumn: "id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_posts_realms_realm_id",
|
||||||
|
table: "posts");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_posts_realm_id",
|
||||||
|
table: "posts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "realm_id",
|
||||||
|
table: "posts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1944
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.Designer.cs
generated
Normal file
1944
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.cs
Normal file
40
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPostSlug : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "language",
|
||||||
|
table: "posts");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "slug",
|
||||||
|
table: "posts",
|
||||||
|
type: "character varying(1024)",
|
||||||
|
maxLength: 1024,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "slug",
|
||||||
|
table: "posts");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "language",
|
||||||
|
table: "posts",
|
||||||
|
type: "character varying(128)",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -558,11 +558,6 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("forwarded_post_id");
|
.HasColumnName("forwarded_post_id");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("character varying(128)")
|
|
||||||
.HasColumnName("language");
|
|
||||||
|
|
||||||
b.Property<Dictionary<string, object>>("Meta")
|
b.Property<Dictionary<string, object>>("Meta")
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("meta");
|
.HasColumnName("meta");
|
||||||
@@ -575,6 +570,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("publisher_id");
|
.HasColumnName("publisher_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RealmId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("realm_id");
|
||||||
|
|
||||||
b.Property<Guid?>("RepliedPostId")
|
b.Property<Guid?>("RepliedPostId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("replied_post_id");
|
.HasColumnName("replied_post_id");
|
||||||
@@ -591,6 +590,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("sensitive_marks");
|
.HasColumnName("sensitive_marks");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("slug");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
@@ -629,6 +633,9 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.HasIndex("PublisherId")
|
b.HasIndex("PublisherId")
|
||||||
.HasDatabaseName("ix_posts_publisher_id");
|
.HasDatabaseName("ix_posts_publisher_id");
|
||||||
|
|
||||||
|
b.HasIndex("RealmId")
|
||||||
|
.HasDatabaseName("ix_posts_realm_id");
|
||||||
|
|
||||||
b.HasIndex("RepliedPostId")
|
b.HasIndex("RepliedPostId")
|
||||||
.HasDatabaseName("ix_posts_replied_post_id");
|
.HasDatabaseName("ix_posts_replied_post_id");
|
||||||
|
|
||||||
@@ -1652,6 +1659,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_posts_publishers_publisher_id");
|
.HasConstraintName("fk_posts_publishers_publisher_id");
|
||||||
|
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Realm.Realm", "Realm")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RealmId")
|
||||||
|
.HasConstraintName("fk_posts_realms_realm_id");
|
||||||
|
|
||||||
b.HasOne("DysonNetwork.Sphere.Post.Post", "RepliedPost")
|
b.HasOne("DysonNetwork.Sphere.Post.Post", "RepliedPost")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("RepliedPostId")
|
.HasForeignKey("RepliedPostId")
|
||||||
@@ -1662,6 +1674,8 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
|
|
||||||
b.Navigation("Publisher");
|
b.Navigation("Publisher");
|
||||||
|
|
||||||
|
b.Navigation("Realm");
|
||||||
|
|
||||||
b.Navigation("RepliedPost");
|
b.Navigation("RepliedPost");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -2,7 +2,6 @@ using System.Net;
|
|||||||
using DysonNetwork.Shared.PageData;
|
using DysonNetwork.Shared.PageData;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Sphere.Post;
|
using DysonNetwork.Sphere.Post;
|
||||||
using DysonNetwork.Sphere.Publisher;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using OpenGraphNet;
|
using OpenGraphNet;
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ public class PostPageData(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +71,4 @@ public class PostPageData(
|
|||||||
["OpenGraph"] = og
|
["OpenGraph"] = og
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
|
||||||
using DysonNetwork.Sphere.Activity;
|
using DysonNetwork.Sphere.Activity;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
@@ -29,7 +28,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
[MaxLength(1024)] public string? Title { get; set; }
|
[MaxLength(1024)] public string? Title { get; set; }
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
[MaxLength(128)] public string? Language { get; set; }
|
[MaxLength(1024)] public string? Slug { get; set; }
|
||||||
public Instant? EditedAt { get; set; }
|
public Instant? EditedAt { get; set; }
|
||||||
public Instant? PublishedAt { get; set; }
|
public Instant? PublishedAt { get; set; }
|
||||||
public PostVisibility Visibility { get; set; } = PostVisibility.Public;
|
public PostVisibility Visibility { get; set; } = PostVisibility.Public;
|
||||||
@@ -54,6 +53,9 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
|
|||||||
public Guid? ForwardedPostId { get; set; }
|
public Guid? ForwardedPostId { get; set; }
|
||||||
public Post? ForwardedPost { get; set; }
|
public Post? ForwardedPost { get; set; }
|
||||||
|
|
||||||
|
public Guid? RealmId { get; set; }
|
||||||
|
public Realm.Realm? Realm { get; set; }
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public List<CloudFileReferenceObject> Attachments { get; set; } = [];
|
[Column(TypeName = "jsonb")] public List<CloudFileReferenceObject> Attachments { get; set; } = [];
|
||||||
|
|
||||||
[JsonIgnore] public NpgsqlTsVector SearchVector { get; set; } = null!;
|
[JsonIgnore] public NpgsqlTsVector SearchVector { get; set; } = null!;
|
||||||
|
@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Content;
|
|||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Sphere.Poll;
|
using DysonNetwork.Sphere.Poll;
|
||||||
|
using DysonNetwork.Sphere.Realm;
|
||||||
using DysonNetwork.Sphere.WebReader;
|
using DysonNetwork.Sphere.WebReader;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -21,7 +22,8 @@ public class PostController(
|
|||||||
PublisherService pub,
|
PublisherService pub,
|
||||||
AccountService.AccountServiceClient accounts,
|
AccountService.AccountServiceClient accounts,
|
||||||
ActionLogService.ActionLogServiceClient als,
|
ActionLogService.ActionLogServiceClient als,
|
||||||
PollService polls
|
PollService polls,
|
||||||
|
RealmService rs
|
||||||
)
|
)
|
||||||
: ControllerBase
|
: ControllerBase
|
||||||
{
|
{
|
||||||
@@ -30,19 +32,25 @@ public class PostController(
|
|||||||
{
|
{
|
||||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
var currentUser = currentUserValue as Account;
|
var currentUser = currentUserValue as Account;
|
||||||
|
|
||||||
var posts = await ps.ListFeaturedPostsAsync(currentUser);
|
var posts = await ps.ListFeaturedPostsAsync(currentUser);
|
||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<List<Post>>> ListPosts(
|
public async Task<ActionResult<List<Post>>> ListPosts(
|
||||||
[FromQuery] int offset = 0,
|
[FromQuery] int offset = 0,
|
||||||
[FromQuery] int take = 20,
|
[FromQuery] int take = 20,
|
||||||
[FromQuery(Name = "pub")] string? pubName = null,
|
[FromQuery(Name = "pub")] string? pubName = null,
|
||||||
|
[FromQuery(Name = "realm")] string? realmName = null,
|
||||||
[FromQuery(Name = "type")] int? type = null,
|
[FromQuery(Name = "type")] int? type = null,
|
||||||
[FromQuery(Name = "categories")] List<string>? categories = null,
|
[FromQuery(Name = "categories")] List<string>? categories = null,
|
||||||
[FromQuery(Name = "tags")] List<string>? tags = null
|
[FromQuery(Name = "tags")] List<string>? tags = null,
|
||||||
|
[FromQuery(Name = "query")] string? queryTerm = null,
|
||||||
|
[FromQuery(Name = "vector")] bool queryVector = false,
|
||||||
|
[FromQuery(Name = "replies")] bool includeReplies = false,
|
||||||
|
[FromQuery(Name = "media")] bool onlyMedia = false,
|
||||||
|
[FromQuery(Name = "shuffle")] bool shuffle = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
@@ -52,36 +60,60 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
|
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
|
||||||
|
var realm = realmName == null ? null : await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmName);
|
||||||
|
|
||||||
var query = db.Posts
|
var query = db.Posts
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
if (publisher != null)
|
if (publisher != null)
|
||||||
query = query.Where(p => p.Publisher.Id == publisher.Id);
|
query = query.Where(p => p.PublisherId == publisher.Id);
|
||||||
|
if (realm != null)
|
||||||
|
query = query.Where(p => p.RealmId == realm.Id);
|
||||||
if (type != null)
|
if (type != null)
|
||||||
query = query.Where(p => p.Type == (PostType)type);
|
query = query.Where(p => p.Type == (PostType)type);
|
||||||
if (categories is { Count: > 0 })
|
if (categories is { Count: > 0 })
|
||||||
query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
|
query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
|
||||||
if (tags is { Count: > 0 })
|
if (tags is { Count: > 0 })
|
||||||
query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
|
query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
|
||||||
|
if (!includeReplies)
|
||||||
|
query = query.Where(e => e.RepliedPostId == null);
|
||||||
|
if (onlyMedia)
|
||||||
|
query = query.Where(e => e.Attachments.Count > 0);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(queryTerm))
|
||||||
|
{
|
||||||
|
if (queryVector)
|
||||||
|
query = query.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(queryTerm)));
|
||||||
|
else
|
||||||
|
query = query.Where(p =>
|
||||||
|
(p.Title != null && EF.Functions.ILike(p.Title, $"%{queryTerm}%")) ||
|
||||||
|
(p.Description != null && EF.Functions.ILike(p.Description, $"%{queryTerm}%")) ||
|
||||||
|
(p.Content != null && EF.Functions.ILike(p.Content, $"%{queryTerm}%"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
query = query
|
query = query
|
||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true);
|
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true);
|
||||||
|
|
||||||
var totalCount = await query
|
var totalCount = await query
|
||||||
.CountAsync();
|
.CountAsync();
|
||||||
|
|
||||||
|
if (shuffle)
|
||||||
|
query = query.OrderBy(e => EF.Functions.Random());
|
||||||
|
else
|
||||||
|
query = query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
|
||||||
|
|
||||||
var posts = await query
|
var posts = await query
|
||||||
.Include(e => e.RepliedPost)
|
.Include(e => e.RepliedPost)
|
||||||
.Include(e => e.ForwardedPost)
|
.Include(e => e.ForwardedPost)
|
||||||
.Where(e => e.RepliedPostId == null)
|
|
||||||
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -92,6 +124,40 @@ public class PostController(
|
|||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{publisherName}/{slug}")]
|
||||||
|
public async Task<ActionResult<Post>> GetPost(string publisherName, string slug)
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
var currentUser = currentUserValue as Account;
|
||||||
|
List<Guid> userFriends = [];
|
||||||
|
if (currentUser != null)
|
||||||
|
{
|
||||||
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
|
{ AccountId = currentUser.Id });
|
||||||
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
|
var post = await db.Posts
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.Where(e => e.Slug == slug && e.Publisher.Name == publisherName)
|
||||||
|
.Include(e => e.Realm)
|
||||||
|
.Include(e => e.Tags)
|
||||||
|
.Include(e => e.Categories)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.Include(e => e.ForwardedPost)
|
||||||
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null) return NotFound();
|
||||||
|
post = await ps.LoadPostInfo(post, currentUser);
|
||||||
|
|
||||||
|
// Track view - use the account ID as viewer ID if user is logged in
|
||||||
|
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
|
||||||
|
|
||||||
|
return Ok(post);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<Post>> GetPost(Guid id)
|
public async Task<ActionResult<Post>> GetPost(Guid id)
|
||||||
{
|
{
|
||||||
@@ -101,7 +167,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,8 +176,11 @@ public class PostController(
|
|||||||
var post = await db.Posts
|
var post = await db.Posts
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
.Include(e => e.Publisher)
|
.Include(e => e.Publisher)
|
||||||
|
.Include(e => e.Realm)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.Include(e => e.ForwardedPost)
|
||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (post is null) return NotFound();
|
if (post is null) return NotFound();
|
||||||
@@ -124,6 +193,7 @@ public class PostController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
|
[Obsolete("Use the new ListPost API")]
|
||||||
public async Task<ActionResult<List<Post>>> SearchPosts(
|
public async Task<ActionResult<List<Post>>> SearchPosts(
|
||||||
[FromQuery] string query,
|
[FromQuery] string query,
|
||||||
[FromQuery] int offset = 0,
|
[FromQuery] int offset = 0,
|
||||||
@@ -140,7 +210,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +222,10 @@ public class PostController(
|
|||||||
if (useVector)
|
if (useVector)
|
||||||
queryable = queryable.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(query)));
|
queryable = queryable.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(query)));
|
||||||
else
|
else
|
||||||
queryable = queryable.Where(p => EF.Functions.ILike(p.Title, $"%{query}%") ||
|
queryable = queryable.Where(p =>
|
||||||
EF.Functions.ILike(p.Description, $"%{query}%") ||
|
(p.Title != null && EF.Functions.ILike(p.Title, $"%{query}%")) ||
|
||||||
EF.Functions.ILike(p.Content, $"%{query}%")
|
(p.Description != null && EF.Functions.ILike(p.Description, $"%{query}%")) ||
|
||||||
|
(p.Content != null && EF.Functions.ILike(p.Content, $"%{query}%"))
|
||||||
);
|
);
|
||||||
|
|
||||||
var totalCount = await queryable.CountAsync();
|
var totalCount = await queryable.CountAsync();
|
||||||
@@ -207,7 +278,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +314,7 @@ public class PostController(
|
|||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id });
|
{ AccountId = currentUser.Id });
|
||||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +356,7 @@ public class PostController(
|
|||||||
{
|
{
|
||||||
[MaxLength(1024)] public string? Title { get; set; }
|
[MaxLength(1024)] public string? Title { get; set; }
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Slug { get; set; }
|
||||||
public string? Content { get; set; }
|
public string? Content { get; set; }
|
||||||
public PostVisibility? Visibility { get; set; } = PostVisibility.Public;
|
public PostVisibility? Visibility { get; set; } = PostVisibility.Public;
|
||||||
public PostType? Type { get; set; }
|
public PostType? Type { get; set; }
|
||||||
@@ -295,6 +367,7 @@ public class PostController(
|
|||||||
public Instant? PublishedAt { get; set; }
|
public Instant? PublishedAt { get; set; }
|
||||||
public Guid? RepliedPostId { get; set; }
|
public Guid? RepliedPostId { get; set; }
|
||||||
public Guid? ForwardedPostId { get; set; }
|
public Guid? ForwardedPostId { get; set; }
|
||||||
|
public Guid? RealmId { get; set; }
|
||||||
|
|
||||||
public Guid? PollId { get; set; }
|
public Guid? PollId { get; set; }
|
||||||
}
|
}
|
||||||
@@ -334,6 +407,7 @@ public class PostController(
|
|||||||
{
|
{
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
|
Slug = request.Slug,
|
||||||
Content = request.Content,
|
Content = request.Content,
|
||||||
Visibility = request.Visibility ?? PostVisibility.Public,
|
Visibility = request.Visibility ?? PostVisibility.Public,
|
||||||
PublishedAt = request.PublishedAt,
|
PublishedAt = request.PublishedAt,
|
||||||
@@ -358,6 +432,15 @@ public class PostController(
|
|||||||
post.ForwardedPostId = forwardedPost.Id;
|
post.ForwardedPostId = forwardedPost.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (request.RealmId is not null)
|
||||||
|
{
|
||||||
|
var realm = await db.Realms.FindAsync(request.RealmId.Value);
|
||||||
|
if (realm is null) return BadRequest("Realm was not found.");
|
||||||
|
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal))
|
||||||
|
return StatusCode(403, "You are not a member of this realm.");
|
||||||
|
post.RealmId = realm.Id;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.PollId.HasValue)
|
if (request.PollId.HasValue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -418,7 +501,7 @@ public class PostController(
|
|||||||
|
|
||||||
var friendsResponse =
|
var friendsResponse =
|
||||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||||
{ AccountId = currentUser.Id.ToString() });
|
{ AccountId = currentUser.Id.ToString() });
|
||||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
@@ -505,11 +588,14 @@ public class PostController(
|
|||||||
|
|
||||||
if (request.Title is not null) post.Title = request.Title;
|
if (request.Title is not null) post.Title = request.Title;
|
||||||
if (request.Description is not null) post.Description = request.Description;
|
if (request.Description is not null) post.Description = request.Description;
|
||||||
|
if (request.Slug is not null) post.Slug = request.Slug;
|
||||||
if (request.Content is not null) post.Content = request.Content;
|
if (request.Content is not null) post.Content = request.Content;
|
||||||
if (request.Visibility is not null) post.Visibility = request.Visibility.Value;
|
if (request.Visibility is not null) post.Visibility = request.Visibility.Value;
|
||||||
if (request.Type is not null) post.Type = request.Type.Value;
|
if (request.Type is not null) post.Type = request.Type.Value;
|
||||||
if (request.Meta is not null) post.Meta = request.Meta;
|
if (request.Meta is not null) post.Meta = request.Meta;
|
||||||
|
|
||||||
|
// All the fields are updated when the request contains the specific fields
|
||||||
|
// But the Poll can be null, so it will be updated whatever it included in requests or not
|
||||||
if (request.PollId.HasValue)
|
if (request.PollId.HasValue)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -530,6 +616,30 @@ public class PostController(
|
|||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (!post.Meta.TryGetValue("embeds", out var existingEmbeds) ||
|
||||||
|
existingEmbeds is not List<EmbeddableBase>)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
// Remove all old poll embeds
|
||||||
|
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The realm is the same as well as the poll
|
||||||
|
if (request.RealmId is not null)
|
||||||
|
{
|
||||||
|
var realm = await db.Realms.FindAsync(request.RealmId.Value);
|
||||||
|
if (realm is null) return BadRequest("Realm was not found.");
|
||||||
|
if (!await rs.IsMemberWithRole(realm.Id, accountId, RealmMemberRole.Normal))
|
||||||
|
return StatusCode(403, "You are not a member of this realm.");
|
||||||
|
post.RealmId = realm.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
post.RealmId = null;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -586,4 +696,4 @@ public class PostController(
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -78,30 +78,25 @@ public class PublisherSubscriptionService(
|
|||||||
queryRequest.Id.AddRange(subscribers.DistinctBy(s => s.AccountId).Select(m => m.AccountId.ToString()));
|
queryRequest.Id.AddRange(subscribers.DistinctBy(s => s.AccountId).Select(m => m.AccountId.ToString()));
|
||||||
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
||||||
|
|
||||||
var notification = new PushNotification
|
|
||||||
{
|
|
||||||
Topic = "posts.new",
|
|
||||||
Title = localizer["PostSubscriptionTitle", post.Publisher.Name, title],
|
|
||||||
Body = message,
|
|
||||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(data),
|
|
||||||
IsSavable = true,
|
|
||||||
ActionUri = $"/posts/{post.Id}"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Notify each subscriber
|
// Notify each subscriber
|
||||||
var notifiedCount = 0;
|
var notifiedCount = 0;
|
||||||
foreach (var target in queryResponse.Accounts)
|
foreach (var target in queryResponse.Accounts.GroupBy(x => x.Language))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
CultureService.SetCultureInfo(target);
|
CultureService.SetCultureInfo(target.Key);
|
||||||
await pusher.SendPushNotificationToUserAsync(
|
var notification = new PushNotification
|
||||||
new SendPushNotificationToUserRequest
|
{
|
||||||
{
|
Topic = "posts.new",
|
||||||
UserId = target.Id,
|
Title = localizer["PostSubscriptionTitle", post.Publisher.Name, title],
|
||||||
Notification = notification
|
Body = message,
|
||||||
}
|
Meta = GrpcTypeHelper.ConvertObjectToByteString(data),
|
||||||
);
|
IsSavable = true,
|
||||||
|
ActionUri = $"/posts/{post.Id}"
|
||||||
|
};
|
||||||
|
var request = new SendPushNotificationToUsersRequest { Notification = notification };
|
||||||
|
request.UserIds.AddRange(target.Select(x => x.Id.ToString()));
|
||||||
|
await pusher.SendPushNotificationToUsersAsync(request);
|
||||||
notifiedCount++;
|
notifiedCount++;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
@@ -204,4 +199,4 @@ public class PublisherSubscriptionService(
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -37,6 +37,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcerExtension_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003Fb5_003F180850e0_003FEnforcerExtension_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcerExtension_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003Fb5_003F180850e0_003FEnforcerExtension_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003F47_003F3a6b6c4b_003FEnforcer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003F47_003F3a6b6c4b_003FEnforcer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe096e6f12c5d6b49356bc34ff1ea08738f910c0929c9d717c9cba7f44288_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe096e6f12c5d6b49356bc34ff1ea08738f910c0929c9d717c9cba7f44288_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F363c1765261146f1a68840a2d3ce7e39291438_003F2a_003F960244de_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcb0587797ea44bd6915ede69888c6766291038_003F55_003F277f2d4c_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcb0587797ea44bd6915ede69888c6766291038_003F55_003F277f2d4c_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4a28847852ee9ba45fd3107526c0a749a733bd4f4ebf33aa3c9a59737a3f758_003FEntityFrameworkServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003F4a28847852ee9ba45fd3107526c0a749a733bd4f4ebf33aa3c9a59737a3f758_003FEntityFrameworkServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnumerable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F832399abc13b45b6bdbabfa022e4a28487e00_003F7f_003F7aece4dd_003FEnumerable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnumerable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F832399abc13b45b6bdbabfa022e4a28487e00_003F7f_003F7aece4dd_003FEnumerable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFFMpeg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003F7e_003F5f02fa0e_003FFFMpeg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFFMpeg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003F7e_003F5f02fa0e_003FFFMpeg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFieldMask_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F68_003Fc6da3cbf_003FFieldMask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFieldMask_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F68_003Fc6da3cbf_003FFieldMask_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFirebaseSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003F5c_003F1f5bca3f_003FFirebaseSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc181aff8c6ec418494a7efcfec578fc154e00_003Fd0_003Fcc905531_003FHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc181aff8c6ec418494a7efcfec578fc154e00_003Fd0_003Fcc905531_003FHttpContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpRequestHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb904f9896c4049fabd596decf1be9c381dc400_003F32_003F906beb77_003FHttpRequestHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpRequestHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb904f9896c4049fabd596decf1be9c381dc400_003F32_003F906beb77_003FHttpRequestHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
Reference in New Issue
Block a user