Compare commits
	
		
			25 Commits
		
	
	
		
			9e1178b7a1
			...
			d441eff2d2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d441eff2d2 | |||
| d31f36d3dc | |||
| 4fc7bd47f9 | |||
| a66037d947 | |||
| bb4e04df0b | |||
| d3752caf1d | |||
| 614c77d7ce | |||
| 5d13f08d47 | |||
| 07ba148d9b | |||
| 917e2d5393 | |||
| e384763faf | |||
| 7fb199b187 | |||
| 924e31aad5 | |||
| 48f776e6ff | |||
| a27bda4720 | |||
| a7e0e1e369 | |||
| 5bb5018cc0 | |||
| a9aab6b7e5 | |||
| 651c06caac | |||
| e0d58085f3 | |||
| cb420c2262 | |||
| 6211f546b1 | |||
| 9070fe7fa3 | |||
| c86d7275ec | |||
| e21bf531e1 | 
| @@ -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) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -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; | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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(), | ||||||
|   | |||||||
| @@ -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 || | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -190,8 +190,6 @@ public class DysonTokenAuthHandler( | |||||||
|                     { |                     { | ||||||
|                         return false; |                         return false; | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|                     break; |  | ||||||
|                 default: |                 default: | ||||||
|                     return false; |                     return false; | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ public class AuthController( | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     [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)), | ||||||
|   | |||||||
| @@ -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(); | ||||||
|   | |||||||
| @@ -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 | ||||||
| { | { | ||||||
|   | |||||||
							
								
								
									
										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); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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); | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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( | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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 | ||||||
|             { |             { | ||||||
|   | |||||||
| @@ -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; | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										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; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
| { | { | ||||||
| @@ -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 | ||||||
|         { |         { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user