Merge branch 'master' into a123lsw-patch-2

This commit is contained in:
2025-08-16 17:53:25 +00:00
32 changed files with 6580 additions and 413 deletions

View File

@@ -9,10 +9,10 @@ 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)
{ {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,12 +267,24 @@ 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.Channels,
s.ChannelLayout,
s.CodecName,
s.Duration,
s.Language,
s.SampleRateHz s.SampleRateHz
}) })
.ToList(), .ToList(),

View File

@@ -509,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()
@@ -527,14 +544,15 @@ public class AccountCurrentController(
} }
} }
[HttpPatch("devices/{id}/label")] [HttpPatch("devices/{deviceId}/label")]
public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string 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.UpdateDeviceName(currentUser, id, label); await accounts.UpdateDeviceName(currentUser, deviceId, label);
return NoContent(); return NoContent();
} }
catch (Exception ex) catch (Exception ex)
@@ -544,6 +562,7 @@ public class AccountCurrentController(
} }
[HttpPatch("devices/current/label")] [HttpPatch("devices/current/label")]
[Authorize]
public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label) 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 ||

View File

@@ -464,7 +464,9 @@ public class AccountService(
public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label) public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
{ {
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == account.Id); var device = await db.AuthClients.FirstOrDefaultAsync(
c => c.DeviceId == deviceId && c.AccountId == account.Id
);
if (device is null) throw new InvalidOperationException("Device was not found."); if (device is null) throw new InvalidOperationException("Device was not found.");
device.DeviceLabel = label; device.DeviceLabel = label;
@@ -506,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

View File

@@ -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<AuthClient> AuthClients { 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)
{ {

View File

@@ -190,8 +190,6 @@ public class DysonTokenAuthHandler(
{ {
return false; return false;
} }
break;
default: default:
return false; return false;
} }

View File

@@ -24,15 +24,15 @@ public class AuthController(
public class ChallengeRequest public class ChallengeRequest
{ {
[Required] public ClientPlatform 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; } [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.");
@@ -48,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)
@@ -55,10 +59,15 @@ 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, request.DeviceName, request.Platform);
var challenge = new AuthChallenge var challenge = new AuthChallenge
{ {
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),

View File

@@ -101,7 +101,6 @@ public class AuthChallenge : ModelBase
}; };
} }
[Index(nameof(DeviceId), IsUnique = true)]
public class AuthClient : ModelBase public class AuthClient : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();

View File

@@ -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
{ {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -944,10 +944,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_clients_account_id"); .HasDatabaseName("ix_auth_clients_account_id");
b.HasIndex("DeviceId")
.IsUnique()
.HasDatabaseName("ix_auth_clients_device_id");
b.ToTable("auth_clients", (string)null); b.ToTable("auth_clients", (string)null);
}); });

View File

@@ -14,170 +14,238 @@
</resheader> </resheader>
<data name="FortuneTipPositiveTitle_1" xml:space="preserve"> <data name="FortuneTipPositiveTitle_1" xml:space="preserve">
<value>抽卡</value> <value>抽卡</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_1" xml:space="preserve"> <data name="FortuneTipPositiveContent_1" xml:space="preserve">
<value>次次出金</value> <value>次次出金</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_2" xml:space="preserve"> <data name="FortuneTipPositiveTitle_2" xml:space="preserve">
<value>游戏</value> <value>游戏</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_2" xml:space="preserve"> <data name="FortuneTipPositiveContent_2" xml:space="preserve">
<value>升段如破竹</value> <value>升段如破竹</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_3" xml:space="preserve"> <data name="FortuneTipPositiveTitle_3" xml:space="preserve">
<value>抽奖</value> <value>抽奖</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_3" xml:space="preserve"> <data name="FortuneTipPositiveContent_3" xml:space="preserve">
<value>欧气加身</value> <value>欧气加身</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_4" xml:space="preserve"> <data name="FortuneTipPositiveTitle_4" xml:space="preserve">
<value>演讲</value> <value>演讲</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_4" xml:space="preserve"> <data name="FortuneTipPositiveContent_4" xml:space="preserve">
<value>妙语连珠</value> <value>妙语连珠</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_5" xml:space="preserve"> <data name="FortuneTipPositiveTitle_5" xml:space="preserve">
<value>绘图</value> <value>绘图</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_5" xml:space="preserve"> <data name="FortuneTipPositiveContent_5" xml:space="preserve">
<value>灵感如泉涌</value> <value>灵感如泉涌</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_6" xml:space="preserve"> <data name="FortuneTipPositiveTitle_6" xml:space="preserve">
<value>编程</value> <value>编程</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_6" xml:space="preserve"> <data name="FortuneTipPositiveContent_6" xml:space="preserve">
<value>0 error(s), 0 warning(s)</value> <value>0 error(s), 0 warning(s)</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_7" xml:space="preserve"> <data name="FortuneTipPositiveTitle_7" xml:space="preserve">
<value>购物</value> <value>购物</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_7" xml:space="preserve"> <data name="FortuneTipPositiveContent_7" xml:space="preserve">
<value>汇率低谷</value> <value>汇率低谷</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_8" xml:space="preserve"> <data name="FortuneTipPositiveTitle_8" xml:space="preserve">
<value>学习</value> <value>学习</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_8" xml:space="preserve"> <data name="FortuneTipPositiveContent_8" xml:space="preserve">
<value>效率 200%</value> <value>效率 200%</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_9" xml:space="preserve"> <data name="FortuneTipPositiveTitle_9" xml:space="preserve">
<value>编曲</value> <value>编曲</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_9" xml:space="preserve"> <data name="FortuneTipPositiveContent_9" xml:space="preserve">
<value>灵感爆棚</value> <value>灵感爆棚</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_10" xml:space="preserve"> <data name="FortuneTipPositiveTitle_10" xml:space="preserve">
<value>摄影</value> <value>摄影</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_10" xml:space="preserve"> <data name="FortuneTipPositiveContent_10" xml:space="preserve">
<value>刀锐奶化</value> <value>刀锐奶化</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_11" xml:space="preserve"> <data name="FortuneTipPositiveTitle_11" xml:space="preserve">
<value>焊 PCB</value> <value>焊 PCB</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_11" xml:space="preserve"> <data name="FortuneTipPositiveContent_11" xml:space="preserve">
<value>上电,启动,好耶!</value> <value>上电,启动,好耶!</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_12" xml:space="preserve"> <data name="FortuneTipPositiveTitle_12" xml:space="preserve">
<value>AE 启动</value> <value>AE 启动</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_12" xml:space="preserve"> <data name="FortuneTipPositiveContent_12" xml:space="preserve">
<value>帧渲染时间 20ms</value> <value>帧渲染时间 20ms</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_13" xml:space="preserve"> <data name="FortuneTipPositiveTitle_13" xml:space="preserve">
<value>航拍</value> <value>航拍</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_13" xml:space="preserve"> <data name="FortuneTipPositiveContent_13" xml:space="preserve">
<value>”可以起飞“</value> <value>”可以起飞“</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveTitle_14" xml:space="preserve"> <data name="FortuneTipPositiveTitle_14" xml:space="preserve">
<value>调色</value> <value>调色</value>
<comment/>
</data> </data>
<data name="FortuneTipPositiveContent_14" xml:space="preserve"> <data name="FortuneTipPositiveContent_14" xml:space="preserve">
<value>色彩准确强如怪,拼尽全力无法战胜</value> <value>色彩准确强如怪,拼尽全力无法战胜</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_1" xml:space="preserve"> <data name="FortuneTipNegativeTitle_1" xml:space="preserve">
<value>抽卡</value> <value>抽卡</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_1" xml:space="preserve"> <data name="FortuneTipNegativeContent_1" xml:space="preserve">
<value>吃大保底</value> <value>吃大保底</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_2" xml:space="preserve"> <data name="FortuneTipNegativeTitle_2" xml:space="preserve">
<value>游戏</value> <value>游戏</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_2" xml:space="preserve"> <data name="FortuneTipNegativeContent_2" xml:space="preserve">
<value>掉分如山崩</value> <value>掉分如山崩</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_3" xml:space="preserve"> <data name="FortuneTipNegativeTitle_3" xml:space="preserve">
<value>抽奖</value> <value>抽奖</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_3" xml:space="preserve"> <data name="FortuneTipNegativeContent_3" xml:space="preserve">
<value>十连皆寂</value> <value>十连皆寂</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_4" xml:space="preserve"> <data name="FortuneTipNegativeTitle_4" xml:space="preserve">
<value>演讲</value> <value>演讲</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_4" xml:space="preserve"> <data name="FortuneTipNegativeContent_4" xml:space="preserve">
<value>谨言慎行</value> <value>谨言慎行</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_5" xml:space="preserve"> <data name="FortuneTipNegativeTitle_5" xml:space="preserve">
<value>绘图</value> <value>绘图</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_5" xml:space="preserve"> <data name="FortuneTipNegativeContent_5" xml:space="preserve">
<value>下笔如千斤</value> <value>下笔如千斤</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_6" xml:space="preserve"> <data name="FortuneTipNegativeTitle_6" xml:space="preserve">
<value>编程</value> <value>编程</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_6" xml:space="preserve"> <data name="FortuneTipNegativeContent_6" xml:space="preserve">
<value>114 error(s), 514 warning(s)</value> <value>114 error(s), 514 warning(s)</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_7" xml:space="preserve"> <data name="FortuneTipNegativeTitle_7" xml:space="preserve">
<value>购物</value> <value>购物</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_7" xml:space="preserve"> <data name="FortuneTipNegativeContent_7" xml:space="preserve">
<value>245% 关税</value> <value>245% 关税</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_8" xml:space="preserve"> <data name="FortuneTipNegativeTitle_8" xml:space="preserve">
<value>学习</value> <value>学习</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_8" xml:space="preserve"> <data name="FortuneTipNegativeContent_8" xml:space="preserve">
<value>效率 50%</value> <value>效率 50%</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_9" xml:space="preserve"> <data name="FortuneTipNegativeTitle_9" xml:space="preserve">
<value>编曲</value> <value>编曲</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_9" xml:space="preserve"> <data name="FortuneTipNegativeContent_9" xml:space="preserve">
<value>FL Studio 未响应</value> <value>FL Studio 未响应</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_10" xml:space="preserve"> <data name="FortuneTipNegativeTitle_10" xml:space="preserve">
<value>摄影</value> <value>摄影</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_10" xml:space="preserve"> <data name="FortuneTipNegativeContent_10" xml:space="preserve">
<value>"No card in camera"</value> <value>"No card in camera"</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_11" xml:space="preserve"> <data name="FortuneTipNegativeTitle_11" xml:space="preserve">
<value>焊 PCB</value> <value>焊 PCB</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_11" xml:space="preserve"> <data name="FortuneTipNegativeContent_11" xml:space="preserve">
<value>斯~ 不烫</value> <value>斯~ 不烫</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_12" xml:space="preserve"> <data name="FortuneTipNegativeTitle_12" xml:space="preserve">
<value>AE 启动</value> <value>AE 启动</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_12" xml:space="preserve"> <data name="FortuneTipNegativeContent_12" xml:space="preserve">
<value>咩~</value> <value>咩~</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_13" xml:space="preserve"> <data name="FortuneTipNegativeTitle_13" xml:space="preserve">
<value>航拍</value> <value>航拍</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_13" xml:space="preserve"> <data name="FortuneTipNegativeContent_13" xml:space="preserve">
<value>谨慎飞行(姿态模式)→ 严重低电压警报 → 遥控器信号丢失</value> <value>谨慎飞行(姿态模式)→ 严重低电压警报 → 遥控器信号丢失</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeTitle_14" xml:space="preserve"> <data name="FortuneTipNegativeTitle_14" xml:space="preserve">
<value>调色</value> <value>调色</value>
<comment/>
</data> </data>
<data name="FortuneTipNegativeContent_14" xml:space="preserve"> <data name="FortuneTipNegativeContent_14" xml:space="preserve">
<value>甲:我要五彩斑斓的黑</value> <value>甲:我要五彩斑斓的黑</value>
<comment/>
</data>
<data name="FortuneTipNegativeTitle_15" xml:space="preserve">
<value>洗胶片</value>
<comment/>
</data>
<data name="FortuneTipPositiveContent_15" xml:space="preserve">
<value>0 水渍</value>
<comment/>
</data>
<data name="FortuneTipNegativeContent_15" xml:space="preserve">
<value>“?暗盒里怎么还有!“</value>
<comment/>
</data> </data>
</root> </root>

View File

@@ -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)
{ {

View File

@@ -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")]
@@ -66,7 +65,6 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
} }
catch (Exception ex) catch (Exception ex)
{ {
if (ex is not WebSocketException)
logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly"); logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly");
} }
finally finally

View File

@@ -127,7 +127,7 @@ public class NotificationController(
[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;
} }
@@ -153,7 +153,7 @@ 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

View File

@@ -3,13 +3,13 @@ using CorePush.Firebase;
using DysonNetwork.Pusher.Connection; using DysonNetwork.Pusher.Connection;
using DysonNetwork.Shared.Cache; 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 FlushBufferService _fbs;
@@ -19,6 +19,11 @@ public class PushService
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,
@@ -56,6 +61,45 @@ public class PushService
_fbs = fbs; _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)
@@ -83,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;
@@ -94,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,
@@ -111,7 +153,7 @@ public class PushService
return subscription; return subscription;
} }
public void SendNotification(Account account, public async Task SendNotification(Account account,
string topic, string topic,
string? title = null, string? title = null,
string? subtitle = null, string? subtitle = null,
@@ -141,7 +183,8 @@ public class PushService
if (save) if (save)
_fbs.Enqueue(notification); _fbs.Enqueue(notification);
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)
@@ -153,18 +196,20 @@ public class PushService
notification.Meta notification.Meta
); );
// WS send: still immediate (fire-and-forget from caller perspective)
_ws.SendPacketToAccount(notification.AccountId.ToString(), new Connection.WebSocketPacket _ws.SendPacketToAccount(notification.AccountId.ToString(), new Connection.WebSocketPacket
{ {
Type = "notifications.new", Type = "notifications.new",
Data = notification Data = notification
}); });
// Pushing the 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() .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)
@@ -175,8 +220,7 @@ 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) public async Task MarkAllNotificationsViewed(Guid accountId)
@@ -190,6 +234,7 @@ public class PushService
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)
{
accounts.ForEach(x => accounts.ForEach(x =>
{ {
var newNotification = new Notification var newNotification = new Notification
@@ -204,6 +249,7 @@ public class PushService
}; };
_fbs.Enqueue(newNotification); _fbs.Enqueue(newNotification);
}); });
}
_logger.LogInformation( _logger.LogInformation(
"Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}", "Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
@@ -212,9 +258,10 @@ public class PushService
notification.Meta notification.Meta
); );
// WS first
foreach (var account in accounts) foreach (var account in accounts)
{ {
notification.AccountId = account; notification.AccountId = account; // keep original behavior
_ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket _ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket
{ {
Type = "notifications.new", Type = "notifications.new",
@@ -222,24 +269,54 @@ public class PushService
}); });
} }
// 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() .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)
{ {
@@ -273,6 +350,7 @@ public class PushService
["title"] = notification.Title ?? string.Empty, ["title"] = notification.Title ?? string.Empty,
["body"] = body ["body"] = body
}, },
// You can re-enable data payloads if needed.
// ["data"] = new Dictionary<string, object> // ["data"] = new Dictionary<string, object>
// { // {
// ["Id"] = notification.Id, // ["Id"] = notification.Id,
@@ -331,7 +409,7 @@ 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(

View File

@@ -85,7 +85,7 @@ public class PusherServiceGrpc(
ServerCallContext context) ServerCallContext context)
{ {
var account = await accountsHelper.GetAccount(Guid.Parse(request.UserId)); var account = await accountsHelper.GetAccount(Guid.Parse(request.UserId));
pushService.SendNotification( await pushService.SendNotification(
account, account,
request.Notification.Topic, request.Notification.Topic,
request.Notification.Title, request.Notification.Title,

View File

@@ -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)
{ {

View File

@@ -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,

View File

@@ -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
{ {

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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

View File

@@ -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;

View File

@@ -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!;

View File

@@ -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
{ {
@@ -40,9 +42,15 @@ public class PostController(
[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);
@@ -59,29 +67,53 @@ public class PostController(
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)
{ {
@@ -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,
@@ -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();
@@ -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
@@ -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
{ {