Merge branch 'master' into a123lsw-patch-2
This commit is contained in:
@@ -9,11 +9,11 @@ public class AppDatabase(
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<Developer> Developers { get; set; }
|
||||
|
||||
public DbSet<CustomApp> CustomApps { get; set; }
|
||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
|
||||
|
||||
public DbSet<Developer> Developers { get; set; } = null!;
|
||||
|
||||
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
|
@@ -8,9 +8,6 @@ using Google.Protobuf.WellKnownTypes;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using NodaTime;
|
||||
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
@@ -134,8 +131,8 @@ public class CustomAppSecret : ModelBase
|
||||
|
||||
public Guid AppId { get; set; }
|
||||
public CustomApp App { get; set; } = null!;
|
||||
|
||||
|
||||
|
||||
|
||||
public static CustomAppSecret FromProtoValue(DysonNetwork.Shared.Proto.CustomAppSecret p)
|
||||
{
|
||||
return new CustomAppSecret
|
||||
@@ -161,4 +158,4 @@ public class CustomAppSecret : ModelBase
|
||||
AppId = Id.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -45,7 +45,7 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
|
||||
/// The object name which stored remotely,
|
||||
/// multiple cloud file may have same storage id to indicate they are the same file
|
||||
///
|
||||
///
|
||||
/// If the storage id was null and the uploaded at is not null, means it is an embedding file,
|
||||
/// The embedding file means the file is store on another site,
|
||||
/// or it is a webpage (based on mimetype)
|
||||
@@ -72,8 +72,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
DeletedAt = DeletedAt,
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
FileMeta = FileMeta,
|
||||
UserMeta = UserMeta,
|
||||
FileMeta = FileMeta ?? [],
|
||||
UserMeta = UserMeta ?? [],
|
||||
SensitiveMarks = SensitiveMarks,
|
||||
MimeType = MimeType,
|
||||
Hash = Hash,
|
||||
@@ -141,4 +141,4 @@ public class CloudFileReference : ModelBase
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,7 +20,6 @@ namespace DysonNetwork.Drive.Storage;
|
||||
public class FileService(
|
||||
AppDatabase db,
|
||||
IConfiguration configuration,
|
||||
TusDiskStore store,
|
||||
ILogger<FileService> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICacheService cache
|
||||
@@ -268,14 +267,26 @@ public class FileService(
|
||||
// Add detailed stream information
|
||||
["video_streams"] = mediaInfo.VideoStreams.Select(s => new
|
||||
{
|
||||
s.AvgFrameRate, s.BitRate, s.CodecName, s.Duration, s.Height, s.Width, s.Language,
|
||||
s.PixelFormat, s.Rotation
|
||||
s.AvgFrameRate,
|
||||
s.BitRate,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Height,
|
||||
s.Width,
|
||||
s.Language,
|
||||
s.PixelFormat,
|
||||
s.Rotation
|
||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||
{
|
||||
s.BitRate, s.Channels, s.ChannelLayout, s.CodecName, s.Duration, s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
{
|
||||
s.BitRate,
|
||||
s.Channels,
|
||||
s.ChannelLayout,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
if (mediaInfo.PrimaryVideoStream is not null)
|
||||
@@ -319,7 +330,7 @@ public class FileService(
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Processing file {FileId} in background...", fileId);
|
||||
|
||||
|
||||
var fileExtension = Path.GetExtension(originalFilePath);
|
||||
|
||||
if (!pool.PolicyConfig.NoOptimization)
|
||||
@@ -869,4 +880,4 @@ file class UpdatableCloudFile(CloudFile file)
|
||||
.SetProperty(f => f.UserMeta, userMeta!)
|
||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
|
||||
@@ -527,14 +544,15 @@ public class AccountCurrentController(
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("devices/{id}/label")]
|
||||
public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string id, [FromBody] string label)
|
||||
[HttpPatch("devices/{deviceId}/label")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.UpdateDeviceName(currentUser, id, label);
|
||||
await accounts.UpdateDeviceName(currentUser, deviceId, label);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -544,6 +562,7 @@ public class AccountCurrentController(
|
||||
}
|
||||
|
||||
[HttpPatch("devices/current/label")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||
@@ -738,4 +757,4 @@ public class AccountCurrentController(
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -464,7 +464,9 @@ public class AccountService(
|
||||
|
||||
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.");
|
||||
|
||||
device.DeviceLabel = label;
|
||||
@@ -492,7 +494,7 @@ public class AccountService(
|
||||
{
|
||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -506,6 +508,36 @@ public class AccountService(
|
||||
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)
|
||||
{
|
||||
var isExists = await db.AccountContacts
|
||||
|
@@ -18,35 +18,35 @@ public class AppDatabase(
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<PermissionNode> PermissionNodes { get; set; }
|
||||
public DbSet<PermissionGroup> PermissionGroups { get; set; }
|
||||
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
||||
public DbSet<PermissionNode> PermissionNodes { get; set; } = null!;
|
||||
public DbSet<PermissionGroup> PermissionGroups { get; set; } = null!;
|
||||
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } = null!;
|
||||
|
||||
public DbSet<MagicSpell> MagicSpells { get; set; }
|
||||
public DbSet<Account.Account> Accounts { get; set; }
|
||||
public DbSet<AccountConnection> AccountConnections { get; set; }
|
||||
public DbSet<AccountProfile> AccountProfiles { get; set; }
|
||||
public DbSet<AccountContact> AccountContacts { get; set; }
|
||||
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
|
||||
public DbSet<Relationship> AccountRelationships { get; set; }
|
||||
public DbSet<Status> AccountStatuses { get; set; }
|
||||
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
|
||||
public DbSet<AccountBadge> Badges { get; set; }
|
||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||
public DbSet<AbuseReport> AbuseReports { get; set; }
|
||||
public DbSet<MagicSpell> MagicSpells { get; set; } = null!;
|
||||
public DbSet<Account.Account> Accounts { get; set; } = null!;
|
||||
public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
|
||||
public DbSet<AccountProfile> AccountProfiles { get; set; } = null!;
|
||||
public DbSet<AccountContact> AccountContacts { get; set; } = null!;
|
||||
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
|
||||
public DbSet<Relationship> AccountRelationships { get; set; } = null!;
|
||||
public DbSet<Status> AccountStatuses { get; set; } = null!;
|
||||
public DbSet<CheckInResult> AccountCheckInResults { get; set; } = null!;
|
||||
public DbSet<AccountBadge> Badges { get; set; } = null!;
|
||||
public DbSet<ActionLog> ActionLogs { get; set; } = null!;
|
||||
public DbSet<AbuseReport> AbuseReports { get; set; } = null!;
|
||||
|
||||
public DbSet<AuthSession> AuthSessions { get; set; }
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; }
|
||||
public DbSet<AuthClient> AuthClients { get; set; }
|
||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||
public DbSet<AuthClient> AuthClients { get; set; } = null!;
|
||||
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; }
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; }
|
||||
public DbSet<Order> PaymentOrders { get; set; }
|
||||
public DbSet<Transaction> PaymentTransactions { get; set; }
|
||||
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
||||
public DbSet<Coupon> WalletCoupons { get; set; }
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
||||
public DbSet<Order> PaymentOrders { get; set; } = null!;
|
||||
public DbSet<Transaction> PaymentTransactions { get; set; } = null!;
|
||||
public DbSet<Subscription> WalletSubscriptions { get; set; } = null!;
|
||||
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)
|
||||
{
|
||||
|
@@ -157,14 +157,14 @@ public class DysonTokenAuthHandler(
|
||||
{
|
||||
// Handle JWT tokens (3 parts)
|
||||
case 3:
|
||||
{
|
||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||
if (!isValid) return false;
|
||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||
if (jti is null) return false;
|
||||
{
|
||||
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||
if (!isValid) return false;
|
||||
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||
if (jti is null) return false;
|
||||
|
||||
return Guid.TryParse(jti, out sessionId);
|
||||
}
|
||||
return Guid.TryParse(jti, out sessionId);
|
||||
}
|
||||
// Handle compact tokens (2 parts)
|
||||
case 2:
|
||||
// Original compact token validation logic
|
||||
@@ -190,8 +190,6 @@ public class DysonTokenAuthHandler(
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -277,4 +275,4 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,19 +20,19 @@ public class AuthController(
|
||||
) : ControllerBase
|
||||
{
|
||||
private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
|
||||
|
||||
|
||||
public class ChallengeRequest
|
||||
{
|
||||
[Required] public ClientPlatform Platform { get; set; }
|
||||
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
|
||||
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
[Required][MaxLength(256)] public string Account { get; set; } = null!;
|
||||
[Required][MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||
public List<string> Audiences { get; set; } = new();
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
}
|
||||
|
||||
[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);
|
||||
if (account is null) return NotFound("Account was not found.");
|
||||
@@ -48,6 +48,10 @@ public class AuthController(
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.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
|
||||
var existingChallenge = await db.AuthChallenges
|
||||
.Where(e => e.AccountId == account.Id)
|
||||
@@ -55,10 +59,15 @@ public class AuthController(
|
||||
.Where(e => e.UserAgent == userAgent)
|
||||
.Where(e => e.StepRemain > 0)
|
||||
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
||||
.Where(e => e.Type == ChallengeType.Login)
|
||||
.Where(e => e.ClientId == device.Id)
|
||||
.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
|
||||
{
|
||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||
@@ -295,4 +304,4 @@ public class AuthController(
|
||||
});
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -101,7 +101,6 @@ public class AuthChallenge : ModelBase
|
||||
};
|
||||
}
|
||||
|
||||
[Index(nameof(DeviceId), IsUnique = true)]
|
||||
public class AuthClient : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -117,7 +116,7 @@ public class AuthClient : ModelBase
|
||||
public class AuthClientWithChallenge : AuthClient
|
||||
{
|
||||
public List<AuthChallenge> Challenges { get; set; } = [];
|
||||
|
||||
|
||||
public static AuthClientWithChallenge FromClient(AuthClient client)
|
||||
{
|
||||
return new AuthClientWithChallenge
|
||||
|
@@ -19,8 +19,7 @@ public class OidcProviderController(
|
||||
AppDatabase db,
|
||||
OidcProviderService oidcService,
|
||||
IConfiguration configuration,
|
||||
IOptions<OidcProviderOptions> options,
|
||||
ILogger<OidcProviderController> logger
|
||||
IOptions<OidcProviderOptions> options
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
@@ -36,74 +35,74 @@ public class OidcProviderController(
|
||||
case "authorization_code" when request.Code == null:
|
||||
return BadRequest("Authorization code is required");
|
||||
case "authorization_code":
|
||||
{
|
||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: request.ClientId.Value,
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
case "refresh_token":
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session?.AppId is null || session.ExpiredAt < now)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid or expired refresh token"
|
||||
});
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: session.AppId!.Value,
|
||||
sessionId: session.Id
|
||||
clientId: request.ClientId.Value,
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
catch (FormatException)
|
||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
case "refresh_token":
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
try
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid refresh token format"
|
||||
});
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session?.AppId is null || session.ExpiredAt < now)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid or expired refresh token"
|
||||
});
|
||||
}
|
||||
|
||||
// Get the client
|
||||
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: session.AppId!.Value,
|
||||
sessionId: session.Id
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_grant",
|
||||
ErrorDescription = "Invalid refresh token format"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||
}
|
||||
@@ -238,4 +237,4 @@ public class TokenRequest
|
||||
[JsonPropertyName("code_verifier")]
|
||||
[FromForm(Name = "code_verifier")]
|
||||
public string? CodeVerifier { get; set; }
|
||||
}
|
||||
}
|
||||
|
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")
|
||||
.HasDatabaseName("ix_auth_clients_account_id");
|
||||
|
||||
b.HasIndex("DeviceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_auth_clients_device_id");
|
||||
|
||||
b.ToTable("auth_clients", (string)null);
|
||||
});
|
||||
|
||||
|
@@ -13,171 +13,239 @@
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="FortuneTipPositiveTitle_1" xml:space="preserve">
|
||||
<value>抽卡</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_1" xml:space="preserve">
|
||||
<value>次次出金</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_2" xml:space="preserve">
|
||||
<value>升段如破竹</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_3" xml:space="preserve">
|
||||
<value>欧气加身</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_4" xml:space="preserve">
|
||||
<value>妙语连珠</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_5" xml:space="preserve">
|
||||
<value>灵感如泉涌</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_6" xml:space="preserve">
|
||||
<value>0 error(s), 0 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_7" xml:space="preserve">
|
||||
<value>购物</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_7" xml:space="preserve">
|
||||
<value>汇率低谷</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_8" xml:space="preserve">
|
||||
<value>学习</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_8" xml:space="preserve">
|
||||
<value>效率 200%</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_9" xml:space="preserve">
|
||||
<value>编曲</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_9" xml:space="preserve">
|
||||
<value>灵感爆棚</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_10" xml:space="preserve">
|
||||
<value>摄影</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_10" xml:space="preserve">
|
||||
<value>刀锐奶化</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_11" xml:space="preserve">
|
||||
<value>焊 PCB</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_11" xml:space="preserve">
|
||||
<value>上电,启动,好耶!</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_12" xml:space="preserve">
|
||||
<value>AE 启动</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_12" xml:space="preserve">
|
||||
<value>帧渲染时间 20ms</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_13" xml:space="preserve">
|
||||
<value>航拍</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_13" xml:space="preserve">
|
||||
<value>”可以起飞“</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_14" xml:space="preserve">
|
||||
<value>调色</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_14" xml:space="preserve">
|
||||
<value>色彩准确强如怪,拼尽全力无法战胜</value>
|
||||
<value>抽卡</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_1" xml:space="preserve">
|
||||
<value>次次出金</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_2" xml:space="preserve">
|
||||
<value>升段如破竹</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_3" xml:space="preserve">
|
||||
<value>欧气加身</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_4" xml:space="preserve">
|
||||
<value>妙语连珠</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_5" xml:space="preserve">
|
||||
<value>灵感如泉涌</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_6" xml:space="preserve">
|
||||
<value>0 error(s), 0 warning(s)</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_7" xml:space="preserve">
|
||||
<value>购物</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_7" xml:space="preserve">
|
||||
<value>汇率低谷</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_8" xml:space="preserve">
|
||||
<value>学习</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_8" xml:space="preserve">
|
||||
<value>效率 200%</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_9" xml:space="preserve">
|
||||
<value>编曲</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_9" xml:space="preserve">
|
||||
<value>灵感爆棚</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_10" xml:space="preserve">
|
||||
<value>摄影</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_10" xml:space="preserve">
|
||||
<value>刀锐奶化</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_11" xml:space="preserve">
|
||||
<value>焊 PCB</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_11" xml:space="preserve">
|
||||
<value>上电,启动,好耶!</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_12" xml:space="preserve">
|
||||
<value>AE 启动</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_12" xml:space="preserve">
|
||||
<value>帧渲染时间 20ms</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_13" xml:space="preserve">
|
||||
<value>航拍</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_13" xml:space="preserve">
|
||||
<value>”可以起飞“</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_14" xml:space="preserve">
|
||||
<value>调色</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_14" xml:space="preserve">
|
||||
<value>色彩准确强如怪,拼尽全力无法战胜</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1" xml:space="preserve">
|
||||
<value>抽卡</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_1" xml:space="preserve">
|
||||
<value>吃大保底</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_2" xml:space="preserve">
|
||||
<value>掉分如山崩</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_3" xml:space="preserve">
|
||||
<value>十连皆寂</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_4" xml:space="preserve">
|
||||
<value>谨言慎行</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_5" xml:space="preserve">
|
||||
<value>下笔如千斤</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_6" xml:space="preserve">
|
||||
<value>114 error(s), 514 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_7" xml:space="preserve">
|
||||
<value>购物</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_7" xml:space="preserve">
|
||||
<value>245% 关税</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_8" xml:space="preserve">
|
||||
<value>学习</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_8" xml:space="preserve">
|
||||
<value>效率 50%</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_9" xml:space="preserve">
|
||||
<value>编曲</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_9" xml:space="preserve">
|
||||
<value>FL Studio 未响应</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_10" xml:space="preserve">
|
||||
<value>摄影</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_10" xml:space="preserve">
|
||||
<value>"No card in camera"</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_11" xml:space="preserve">
|
||||
<value>焊 PCB</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_11" xml:space="preserve">
|
||||
<value>斯~ 不烫</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_12" xml:space="preserve">
|
||||
<value>AE 启动</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_12" xml:space="preserve">
|
||||
<value>咩~</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_13" xml:space="preserve">
|
||||
<value>航拍</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_13" xml:space="preserve">
|
||||
<value>谨慎飞行(姿态模式)→ 严重低电压警报 → 遥控器信号丢失</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_14" xml:space="preserve">
|
||||
<value>调色</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_14" xml:space="preserve">
|
||||
<value>甲:我要五彩斑斓的黑</value>
|
||||
<value>抽卡</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_1" xml:space="preserve">
|
||||
<value>吃大保底</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_2" xml:space="preserve">
|
||||
<value>掉分如山崩</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_3" xml:space="preserve">
|
||||
<value>十连皆寂</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_4" xml:space="preserve">
|
||||
<value>谨言慎行</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_5" xml:space="preserve">
|
||||
<value>下笔如千斤</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_6" xml:space="preserve">
|
||||
<value>114 error(s), 514 warning(s)</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_7" xml:space="preserve">
|
||||
<value>购物</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_7" xml:space="preserve">
|
||||
<value>245% 关税</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_8" xml:space="preserve">
|
||||
<value>学习</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_8" xml:space="preserve">
|
||||
<value>效率 50%</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_9" xml:space="preserve">
|
||||
<value>编曲</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_9" xml:space="preserve">
|
||||
<value>FL Studio 未响应</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_10" xml:space="preserve">
|
||||
<value>摄影</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_10" xml:space="preserve">
|
||||
<value>"No card in camera"</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_11" xml:space="preserve">
|
||||
<value>焊 PCB</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_11" xml:space="preserve">
|
||||
<value>斯~ 不烫</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_12" xml:space="preserve">
|
||||
<value>AE 启动</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_12" xml:space="preserve">
|
||||
<value>咩~</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_13" xml:space="preserve">
|
||||
<value>航拍</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_13" xml:space="preserve">
|
||||
<value>谨慎飞行(姿态模式)→ 严重低电压警报 → 遥控器信号丢失</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_14" xml:space="preserve">
|
||||
<value>调色</value>
|
||||
<comment/>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_14" xml:space="preserve">
|
||||
<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>
|
||||
</root>
|
||||
|
@@ -16,7 +16,7 @@ public class AppDatabase(
|
||||
) : DbContext(options)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -175,4 +175,4 @@ public static class OptionalQueryExtensions
|
||||
{
|
||||
return condition ? transform(source) : source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ using Swashbuckle.AspNetCore.Annotations;
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
[ApiController]
|
||||
[Route("/ws")]
|
||||
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
|
||||
{
|
||||
[Route("/ws")]
|
||||
@@ -66,8 +65,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
}
|
||||
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
|
||||
{
|
||||
|
@@ -127,7 +127,7 @@ public class NotificationController(
|
||||
[Required][MaxLength(1024)] public string Title { get; set; } = null!;
|
||||
[MaxLength(2048)] public string? Subtitle { get; set; }
|
||||
[Required][MaxLength(4096)] public string Content { get; set; } = null!;
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
public Dictionary<string, object?>? Meta { get; set; }
|
||||
public int Priority { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public class NotificationController(
|
||||
Title = request.Title,
|
||||
Subtitle = request.Subtitle,
|
||||
Content = request.Content,
|
||||
Meta = request.Meta,
|
||||
Meta = request.Meta ?? [],
|
||||
},
|
||||
request.AccountId,
|
||||
save
|
||||
|
@@ -3,13 +3,13 @@ using CorePush.Firebase;
|
||||
using DysonNetwork.Pusher.Connection;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace DysonNetwork.Pusher.Notification;
|
||||
|
||||
public class PushService
|
||||
public class PushService : IDisposable
|
||||
{
|
||||
private readonly AppDatabase _db;
|
||||
private readonly FlushBufferService _fbs;
|
||||
@@ -19,6 +19,11 @@ public class PushService
|
||||
private readonly ApnSender? _apns;
|
||||
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(
|
||||
IConfiguration config,
|
||||
AppDatabase db,
|
||||
@@ -56,6 +61,45 @@ public class PushService
|
||||
_fbs = fbs;
|
||||
_ws = ws;
|
||||
_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)
|
||||
@@ -83,7 +127,6 @@ public class PushService
|
||||
|
||||
if (existingSubscription != null)
|
||||
{
|
||||
// Update existing subscription
|
||||
existingSubscription.DeviceId = deviceId;
|
||||
existingSubscription.DeviceToken = deviceToken;
|
||||
existingSubscription.Provider = provider;
|
||||
@@ -94,7 +137,6 @@ public class PushService
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
var subscription = new PushSubscription
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
@@ -111,7 +153,7 @@ public class PushService
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public void SendNotification(Account account,
|
||||
public async Task SendNotification(Account account,
|
||||
string topic,
|
||||
string? title = null,
|
||||
string? subtitle = null,
|
||||
@@ -141,7 +183,8 @@ public class PushService
|
||||
if (save)
|
||||
_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)
|
||||
@@ -153,18 +196,20 @@ public class PushService
|
||||
notification.Meta
|
||||
);
|
||||
|
||||
// WS send: still immediate (fire-and-forget from caller perspective)
|
||||
_ws.SendPacketToAccount(notification.AccountId.ToString(), new Connection.WebSocketPacket
|
||||
{
|
||||
Type = "notifications.new",
|
||||
Data = notification
|
||||
});
|
||||
|
||||
// Pushing the notification
|
||||
// Query subscribers and enqueue push work (non-blocking to the HTTP request)
|
||||
var subscribers = await _db.PushSubscriptions
|
||||
.Where(s => s.AccountId == notification.AccountId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
await _PushNotification(notification, subscribers);
|
||||
|
||||
await EnqueuePushWork(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||
@@ -175,8 +220,7 @@ public class PushService
|
||||
|
||||
await _db.Notifications
|
||||
.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)
|
||||
@@ -190,6 +234,7 @@ public class PushService
|
||||
public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
accounts.ForEach(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
@@ -204,6 +249,7 @@ public class PushService
|
||||
};
|
||||
_fbs.Enqueue(newNotification);
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
|
||||
@@ -212,9 +258,10 @@ public class PushService
|
||||
notification.Meta
|
||||
);
|
||||
|
||||
// WS first
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
notification.AccountId = account;
|
||||
notification.AccountId = account; // keep original behavior
|
||||
_ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket
|
||||
{
|
||||
Type = "notifications.new",
|
||||
@@ -222,25 +269,55 @@ public class PushService
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all subscribers once and enqueue to workers
|
||||
var subscribers = await _db.PushSubscriptions
|
||||
.Where(s => accounts.Contains(s.AccountId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
await _PushNotification(notification, subscribers);
|
||||
|
||||
await EnqueuePushWork(notification, subscribers);
|
||||
}
|
||||
|
||||
private async Task _PushNotification(
|
||||
Notification notification,
|
||||
IEnumerable<PushSubscription> subscriptions
|
||||
)
|
||||
private async Task EnqueuePushWork(Notification notification, IEnumerable<PushSubscription> subscriptions)
|
||||
{
|
||||
var tasks = subscriptions
|
||||
.Select(subscription => _PushSingleNotification(notification, subscription))
|
||||
.ToList();
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
try
|
||||
@@ -273,6 +350,7 @@ public class PushService
|
||||
["title"] = notification.Title ?? string.Empty,
|
||||
["body"] = body
|
||||
},
|
||||
// You can re-enable data payloads if needed.
|
||||
// ["data"] = new Dictionary<string, object>
|
||||
// {
|
||||
// ["Id"] = notification.Id,
|
||||
@@ -331,10 +409,10 @@ public class PushService
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
$"Failed to push notification #{notification.Id} to device {subscription.DeviceId}. {ex.Message}");
|
||||
throw new Exception($"Failed to send notification to {subscription.Provider}: {ex.Message}", ex);
|
||||
// Swallow here to keep worker alive; upstream is fire-and-forget.
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -85,7 +85,7 @@ public class PusherServiceGrpc(
|
||||
ServerCallContext context)
|
||||
{
|
||||
var account = await accountsHelper.GetAccount(Guid.Parse(request.UserId));
|
||||
pushService.SendNotification(
|
||||
await pushService.SendNotification(
|
||||
account,
|
||||
request.Notification.Topic,
|
||||
request.Notification.Title,
|
||||
@@ -141,4 +141,4 @@ public class PusherServiceGrpc(
|
||||
|
||||
return Task.FromResult(new GetWebsocketConnectionStatusResponse { IsConnected = isConnected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -24,37 +24,37 @@ public class AppDatabase(
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<Publisher.Publisher> Publishers { get; set; }
|
||||
public DbSet<PublisherMember> PublisherMembers { get; set; }
|
||||
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }
|
||||
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
|
||||
public DbSet<Publisher.Publisher> Publishers { get; set; } = null!;
|
||||
public DbSet<PublisherMember> PublisherMembers { get; set; } = null!;
|
||||
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; } = null!;
|
||||
public DbSet<PublisherFeature> PublisherFeatures { get; set; } = null!;
|
||||
|
||||
public DbSet<Post.Post> Posts { get; set; }
|
||||
public DbSet<PostReaction> PostReactions { get; set; }
|
||||
public DbSet<PostTag> PostTags { get; set; }
|
||||
public DbSet<PostCategory> PostCategories { get; set; }
|
||||
public DbSet<PostCollection> PostCollections { get; set; }
|
||||
public DbSet<PostFeaturedRecord> PostFeaturedRecords { get; set; }
|
||||
public DbSet<Post.Post> Posts { get; set; } = null!;
|
||||
public DbSet<PostReaction> PostReactions { get; set; } = null!;
|
||||
public DbSet<PostTag> PostTags { get; set; } = null!;
|
||||
public DbSet<PostCategory> PostCategories { get; set; } = null!;
|
||||
public DbSet<PostCollection> PostCollections { get; set; } = null!;
|
||||
public DbSet<PostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
|
||||
|
||||
public DbSet<Poll.Poll> Polls { get; set; }
|
||||
public DbSet<Poll.PollQuestion> PollQuestions { get; set; }
|
||||
public DbSet<Poll.PollAnswer> PollAnswers { get; set; }
|
||||
public DbSet<Poll.Poll> Polls { get; set; } = null!;
|
||||
public DbSet<Poll.PollQuestion> PollQuestions { get; set; } = null!;
|
||||
public DbSet<Poll.PollAnswer> PollAnswers { get; set; } = null!;
|
||||
|
||||
public DbSet<Realm.Realm> Realms { get; set; }
|
||||
public DbSet<RealmMember> RealmMembers { get; set; }
|
||||
public DbSet<Realm.Realm> Realms { get; set; } = null!;
|
||||
public DbSet<RealmMember> RealmMembers { get; set; } = null!;
|
||||
|
||||
public DbSet<ChatRoom> ChatRooms { get; set; }
|
||||
public DbSet<ChatMember> ChatMembers { get; set; }
|
||||
public DbSet<Message> ChatMessages { get; set; }
|
||||
public DbSet<RealtimeCall> ChatRealtimeCall { get; set; }
|
||||
public DbSet<MessageReaction> ChatReactions { get; set; }
|
||||
public DbSet<ChatRoom> ChatRooms { get; set; } = null!;
|
||||
public DbSet<ChatMember> ChatMembers { get; set; } = null!;
|
||||
public DbSet<Message> ChatMessages { get; set; } = null!;
|
||||
public DbSet<RealtimeCall> ChatRealtimeCall { get; set; } = null!;
|
||||
public DbSet<MessageReaction> ChatReactions { get; set; } = null!;
|
||||
|
||||
public DbSet<Sticker.Sticker> Stickers { get; set; }
|
||||
public DbSet<StickerPack> StickerPacks { get; set; }
|
||||
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; }
|
||||
public DbSet<Sticker.Sticker> Stickers { get; set; } = null!;
|
||||
public DbSet<StickerPack> StickerPacks { get; set; } = null!;
|
||||
public DbSet<StickerPackOwnership> StickerPackOwnerships { get; set; } = null!;
|
||||
|
||||
public DbSet<WebReader.WebArticle> WebArticles { get; set; }
|
||||
public DbSet<WebReader.WebFeed> WebFeeds { get; set; }
|
||||
public DbSet<WebReader.WebArticle> WebArticles { get; set; } = null!;
|
||||
public DbSet<WebReader.WebFeed> WebFeeds { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
|
@@ -25,7 +25,7 @@ public class ChatRoom : ModelBase, IIdentifiedResource
|
||||
// Outdated fields, for backward compability
|
||||
[MaxLength(32)] public string? PictureId { get; set; }
|
||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||
|
||||
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
@@ -84,7 +84,7 @@ public class ChatMember : ModelBase
|
||||
public Instant? JoinedAt { get; set; }
|
||||
public Instant? LeaveAt { get; set; }
|
||||
public bool IsBot { get; set; } = false;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The break time is the user doesn't receive any message from this member for a while.
|
||||
/// Expect mentioned him or her.
|
||||
@@ -115,7 +115,7 @@ public class ChatMemberTransmissionObject : ModelBase
|
||||
public Instant? JoinedAt { get; set; }
|
||||
public Instant? LeaveAt { get; set; }
|
||||
public bool IsBot { get; set; } = false;
|
||||
|
||||
|
||||
public Instant? BreakUntil { get; set; }
|
||||
public Instant? TimeoutUntil { get; set; }
|
||||
public ChatTimeoutCause? TimeoutCause { get; set; }
|
||||
@@ -127,7 +127,7 @@ public class ChatMemberTransmissionObject : ModelBase
|
||||
Id = member.Id,
|
||||
ChatRoomId = member.ChatRoomId,
|
||||
AccountId = member.AccountId,
|
||||
Account = member.Account,
|
||||
Account = member.Account!,
|
||||
Nick = member.Nick,
|
||||
Role = member.Role,
|
||||
Notify = member.Notify,
|
||||
@@ -142,4 +142,4 @@ public class ChatMemberTransmissionObject : ModelBase
|
||||
DeletedAt = member.DeletedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -164,7 +164,7 @@ public class ChatRoomController(
|
||||
|
||||
public class ChatRoomRequest
|
||||
{
|
||||
[Required] [MaxLength(1024)] public string? Name { get; set; }
|
||||
[Required][MaxLength(1024)] public string? Name { get; set; }
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
[MaxLength(32)] public string? PictureId { get; set; }
|
||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||
@@ -576,7 +576,7 @@ public class ChatRoomController(
|
||||
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.");
|
||||
|
||||
var chatRoom = await db.ChatRooms
|
||||
@@ -970,7 +970,7 @@ public class ChatRoomController(
|
||||
? localizer["ChatInviteDirectBody", sender.Nick]
|
||||
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
|
||||
|
||||
CultureService.SetCultureInfo(member.Account.Language);
|
||||
CultureService.SetCultureInfo(member.Account!.Language);
|
||||
await pusher.SendPushNotificationToUserAsync(
|
||||
new SendPushNotificationToUserRequest
|
||||
{
|
||||
@@ -985,4 +985,4 @@ public class ChatRoomController(
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -254,14 +254,36 @@ public partial class ChatService(
|
||||
notification.Body = "Call begun";
|
||||
break;
|
||||
default:
|
||||
var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments";
|
||||
notification.Body = !string.IsNullOrEmpty(message.Content)
|
||||
? 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;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
foreach (
|
||||
var member in members
|
||||
@@ -279,17 +301,6 @@ public partial class ChatService(
|
||||
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
|
||||
.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)
|
||||
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)
|
||||
.ToList();
|
||||
|
||||
// Remove messages with no sender
|
||||
foreach (var key in messagesToRemove)
|
||||
{
|
||||
messages.Remove(key);
|
||||
}
|
||||
|
||||
// Update remaining messages with their senders
|
||||
foreach (var message in messages)
|
||||
{
|
||||
message.Value!.Sender = messageSenders.First(x => x.Id == message.Value.SenderId);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -647,4 +654,4 @@ public class SyncResponse
|
||||
{
|
||||
public List<MessageChange> Changes { get; set; } = [];
|
||||
public Instant CurrentTimestamp { get; set; }
|
||||
}
|
||||
}
|
||||
|
1944
DysonNetwork.Sphere/Migrations/20250814183405_AddRealmPost.Designer.cs
generated
Normal file
1944
DysonNetwork.Sphere/Migrations/20250814183405_AddRealmPost.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRealmPost : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "realm_id",
|
||||
table: "posts",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_posts_realm_id",
|
||||
table: "posts",
|
||||
column: "realm_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_posts_realms_realm_id",
|
||||
table: "posts",
|
||||
column: "realm_id",
|
||||
principalTable: "realms",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_posts_realms_realm_id",
|
||||
table: "posts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_posts_realm_id",
|
||||
table: "posts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "realm_id",
|
||||
table: "posts");
|
||||
}
|
||||
}
|
||||
}
|
1944
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.Designer.cs
generated
Normal file
1944
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.cs
Normal file
40
DysonNetwork.Sphere/Migrations/20250815041220_AddPostSlug.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPostSlug : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "language",
|
||||
table: "posts");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "slug",
|
||||
table: "posts",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "slug",
|
||||
table: "posts");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "language",
|
||||
table: "posts",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
@@ -558,11 +558,6 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("forwarded_post_id");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("language");
|
||||
|
||||
b.Property<Dictionary<string, object>>("Meta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta");
|
||||
@@ -575,6 +570,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.Property<Guid?>("RealmId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("realm_id");
|
||||
|
||||
b.Property<Guid?>("RepliedPostId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("replied_post_id");
|
||||
@@ -591,6 +590,11 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
@@ -629,6 +633,9 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.HasIndex("PublisherId")
|
||||
.HasDatabaseName("ix_posts_publisher_id");
|
||||
|
||||
b.HasIndex("RealmId")
|
||||
.HasDatabaseName("ix_posts_realm_id");
|
||||
|
||||
b.HasIndex("RepliedPostId")
|
||||
.HasDatabaseName("ix_posts_replied_post_id");
|
||||
|
||||
@@ -1652,6 +1659,11 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.IsRequired()
|
||||
.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")
|
||||
.WithMany()
|
||||
.HasForeignKey("RepliedPostId")
|
||||
@@ -1662,6 +1674,8 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
|
||||
b.Navigation("Publisher");
|
||||
|
||||
b.Navigation("Realm");
|
||||
|
||||
b.Navigation("RepliedPost");
|
||||
});
|
||||
|
||||
|
@@ -2,7 +2,6 @@ using System.Net;
|
||||
using DysonNetwork.Shared.PageData;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Sphere.Post;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenGraphNet;
|
||||
|
||||
@@ -39,7 +38,7 @@ public class PostPageData(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -72,4 +71,4 @@ public class PostPageData(
|
||||
["OpenGraph"] = og
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Sphere.Activity;
|
||||
using NodaTime;
|
||||
using NpgsqlTypes;
|
||||
@@ -29,7 +28,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(1024)] public string? Title { 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? PublishedAt { get; set; }
|
||||
public PostVisibility Visibility { get; set; } = PostVisibility.Public;
|
||||
@@ -54,6 +53,9 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
|
||||
public Guid? ForwardedPostId { 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; } = [];
|
||||
|
||||
[JsonIgnore] public NpgsqlTsVector SearchVector { get; set; } = null!;
|
||||
|
@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Content;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Sphere.Poll;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
using DysonNetwork.Sphere.WebReader;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -21,7 +22,8 @@ public class PostController(
|
||||
PublisherService pub,
|
||||
AccountService.AccountServiceClient accounts,
|
||||
ActionLogService.ActionLogServiceClient als,
|
||||
PollService polls
|
||||
PollService polls,
|
||||
RealmService rs
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
@@ -30,19 +32,25 @@ public class PostController(
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
var currentUser = currentUserValue as Account;
|
||||
|
||||
|
||||
var posts = await ps.ListFeaturedPostsAsync(currentUser);
|
||||
return Ok(posts);
|
||||
}
|
||||
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<Post>>> ListPosts(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery(Name = "pub")] string? pubName = null,
|
||||
[FromQuery(Name = "realm")] string? realmName = null,
|
||||
[FromQuery(Name = "type")] int? type = 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);
|
||||
@@ -52,36 +60,60 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
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 realm = realmName == null ? null : await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmName);
|
||||
|
||||
var query = db.Posts
|
||||
.Include(e => e.Categories)
|
||||
.Include(e => e.Tags)
|
||||
.AsQueryable();
|
||||
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)
|
||||
query = query.Where(p => p.Type == (PostType)type);
|
||||
if (categories is { Count: > 0 })
|
||||
query = query.Where(p => p.Categories.Any(c => categories.Contains(c.Slug)));
|
||||
if (tags is { Count: > 0 })
|
||||
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
|
||||
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true);
|
||||
|
||||
var totalCount = await query
|
||||
.CountAsync();
|
||||
|
||||
if (shuffle)
|
||||
query = query.OrderBy(e => EF.Functions.Random());
|
||||
else
|
||||
query = query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
|
||||
|
||||
var posts = await query
|
||||
.Include(e => e.RepliedPost)
|
||||
.Include(e => e.ForwardedPost)
|
||||
.Where(e => e.RepliedPostId == null)
|
||||
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
@@ -92,6 +124,40 @@ public class PostController(
|
||||
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}")]
|
||||
public async Task<ActionResult<Post>> GetPost(Guid id)
|
||||
{
|
||||
@@ -101,7 +167,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -110,8 +176,11 @@ public class PostController(
|
||||
var post = await db.Posts
|
||||
.Where(e => e.Id == id)
|
||||
.Include(e => e.Publisher)
|
||||
.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();
|
||||
@@ -124,6 +193,7 @@ public class PostController(
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
[Obsolete("Use the new ListPost API")]
|
||||
public async Task<ActionResult<List<Post>>> SearchPosts(
|
||||
[FromQuery] string query,
|
||||
[FromQuery] int offset = 0,
|
||||
@@ -140,7 +210,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -152,9 +222,10 @@ public class PostController(
|
||||
if (useVector)
|
||||
queryable = queryable.Where(p => p.SearchVector.Matches(EF.Functions.ToTsQuery(query)));
|
||||
else
|
||||
queryable = queryable.Where(p => EF.Functions.ILike(p.Title, $"%{query}%") ||
|
||||
EF.Functions.ILike(p.Description, $"%{query}%") ||
|
||||
EF.Functions.ILike(p.Content, $"%{query}%")
|
||||
queryable = queryable.Where(p =>
|
||||
(p.Title != null && EF.Functions.ILike(p.Title, $"%{query}%")) ||
|
||||
(p.Description != null && EF.Functions.ILike(p.Description, $"%{query}%")) ||
|
||||
(p.Content != null && EF.Functions.ILike(p.Content, $"%{query}%"))
|
||||
);
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
@@ -207,7 +278,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -243,7 +314,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -285,6 +356,7 @@ public class PostController(
|
||||
{
|
||||
[MaxLength(1024)] public string? Title { get; set; }
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
[MaxLength(1024)] public string? Slug { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public PostVisibility? Visibility { get; set; } = PostVisibility.Public;
|
||||
public PostType? Type { get; set; }
|
||||
@@ -295,6 +367,7 @@ public class PostController(
|
||||
public Instant? PublishedAt { get; set; }
|
||||
public Guid? RepliedPostId { get; set; }
|
||||
public Guid? ForwardedPostId { get; set; }
|
||||
public Guid? RealmId { get; set; }
|
||||
|
||||
public Guid? PollId { get; set; }
|
||||
}
|
||||
@@ -334,6 +407,7 @@ public class PostController(
|
||||
{
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
Slug = request.Slug,
|
||||
Content = request.Content,
|
||||
Visibility = request.Visibility ?? PostVisibility.Public,
|
||||
PublishedAt = request.PublishedAt,
|
||||
@@ -358,6 +432,15 @@ public class PostController(
|
||||
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)
|
||||
{
|
||||
try
|
||||
@@ -418,7 +501,7 @@ public class PostController(
|
||||
|
||||
var friendsResponse =
|
||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||
|
||||
@@ -505,11 +588,14 @@ public class PostController(
|
||||
|
||||
if (request.Title is not null) post.Title = request.Title;
|
||||
if (request.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.Visibility is not null) post.Visibility = request.Visibility.Value;
|
||||
if (request.Type is not null) post.Type = request.Type.Value;
|
||||
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)
|
||||
{
|
||||
try
|
||||
@@ -530,6 +616,30 @@ public class PostController(
|
||||
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
|
||||
{
|
||||
@@ -586,4 +696,4 @@ public class PostController(
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user