Compare commits

...

28 Commits

Author SHA1 Message Date
LittleSheep
fb6721cb1b 💄 Optimize punishment reason display 2025-08-26 20:32:07 +08:00
LittleSheep
9fcb169c94 🐛 Fix chat room invites 2025-08-26 19:08:23 +08:00
LittleSheep
572874431d 🐛 Fix sticker perm check 2025-08-26 14:48:30 +08:00
LittleSheep
f595ac8001 🐛 Fix uploading file didn't uploaded 2025-08-26 13:02:51 +08:00
LittleSheep
18674e0e1d Remove /cgi directly handled by gateway 2025-08-26 02:59:51 +08:00
LittleSheep
da4c4d3a84 🐛 Fix bugs 2025-08-26 02:48:16 +08:00
LittleSheep
aec01b117d 🐛 Fix chat service duplicate notifying 2025-08-26 00:15:39 +08:00
LittleSheep
d299c32e35 ♻️ Clean up OIDC provider 2025-08-25 23:53:04 +08:00
LittleSheep
344007af66 🔊 Logging more ip address 2025-08-25 23:42:41 +08:00
LittleSheep
d4de5aeac2 🐛 Fix api key exists cause regular login 500 2025-08-25 23:30:41 +08:00
LittleSheep
8ce5ba50f4 🐛 Fix api key cause 401 in other serivces 2025-08-25 23:20:27 +08:00
LittleSheep
5a44952b27 🐛 Fix oidc token aud 2025-08-25 23:17:40 +08:00
LittleSheep
c30946daf6 🐛 Still bug fixes in auth service 2025-08-25 23:01:17 +08:00
LittleSheep
0221d7b294 🐛 Fix compress GIF wrongly 2025-08-25 22:42:14 +08:00
LittleSheep
c44b0b64c3 🐛 Fix api key auth issue 2025-08-25 22:39:35 +08:00
LittleSheep
442ee3bcfd 🐛 Fixes in auth service 2025-08-25 22:24:18 +08:00
LittleSheep
081815c512 Trying to optimize pusher serivce 2025-08-25 21:48:07 +08:00
LittleSheep
eab2a388ae 🐛 Fixes in authorize 2025-08-25 21:22:04 +08:00
LittleSheep
5f7ab49abb 🛂 Add permission check in post pin / unpin 2025-08-25 20:04:21 +08:00
LittleSheep
4ff89173b2 ♻️ Some optimzations for sync message endpoint 2025-08-25 19:24:42 +08:00
LittleSheep
f2052410c7 Filtered realm posts 2025-08-25 17:47:30 +08:00
LittleSheep
83a49be725 🐛 Fix websocket missing in notification 2025-08-25 17:43:37 +08:00
LittleSheep
9b205a73fd 💄 Optimize post controller 2025-08-25 17:06:21 +08:00
LittleSheep
d5157eb7e3 Post category tags subscriptions 2025-08-25 14:18:14 +08:00
LittleSheep
75c92c51db 🐛 Dozens of bug fixes 2025-08-25 13:43:40 +08:00
LittleSheep
915054fce0 Pinned post 2025-08-25 13:37:25 +08:00
LittleSheep
63653680ba 👔 Update the algorithm to pick featured post 2025-08-25 13:06:09 +08:00
LittleSheep
84c4df6620 👔 Prevent from creating duplicate featured record 2025-08-25 13:05:34 +08:00
41 changed files with 5036 additions and 274 deletions

View File

@@ -337,8 +337,14 @@ public class FileService(
if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0])
{
case "image" when !AnimatedImageTypes.Contains(contentType) &&
!AnimatedImageExtensions.Contains(fileExtension):
case "image":
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
{
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
uploads.Add((originalFilePath, string.Empty, contentType, false));
break;
}
newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath))
{
@@ -672,8 +678,8 @@ public class FileService(
foreach (var file in fileGroup)
{
objectsToDelete.Add(file.StorageId ?? file.Id);
if(file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
if(file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
}
await client.RemoveObjectsAsync(

View File

@@ -76,26 +76,6 @@ public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
var gatewayServiceName = _configuration["Service:Name"];
// Add direct route for /cgi to Gateway
var gatewayCluster = new ClusterConfig
{
ClusterId = "gateway-self",
Destinations = new Dictionary<string, DestinationConfig>
{
{ "self", new DestinationConfig { Address = _configuration["Kestrel:Endpoints:Http:Url"] ?? "http://localhost:5000" } }
}
};
clusters.Add(gatewayCluster);
var cgiRoute = new RouteConfig
{
RouteId = "gateway-cgi-route",
ClusterId = "gateway-self",
Match = new RouteMatch { Path = "/cgi/{**catch-all}" }
};
routes.Add(cgiRoute);
_logger.LogInformation(" Added CGI Route: /cgi/** -> Gateway");
// Add direct routes
foreach (var directRoute in directRoutes)
{

View File

@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
try
{
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token);
// Get client IP address
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
if (!valid || session is null)
return AuthenticateResult.Fail(message ?? "Authentication failed.");
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
};
// Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)

View File

@@ -51,7 +51,11 @@ public class AuthController(
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
.FirstOrDefaultAsync();
if (punishment is not null) return StatusCode(423, punishment);
if (punishment is not null)
return StatusCode(
423,
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();

View File

@@ -52,7 +52,7 @@ public class AuthService(
riskScore += 1;
else
{
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1;
}

View File

@@ -1,9 +1,5 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth;
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
ServerCallContext context
)
{
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token);
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
if (!valid || session is null)
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };

View File

@@ -126,7 +126,6 @@ public class OidcProviderController(
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
[FromForm] string? scope = null,
[FromForm] string? state = null,
[FromForm(Name = "response_type")] string? responseType = null,
[FromForm] string? nonce = null,
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
[FromForm(Name = "code_challenge_method")]
@@ -156,7 +155,7 @@ public class OidcProviderController(
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
errorUri.Query = queryParams.ToString();
return Redirect(errorUri.Uri.ToString());
return Ok(new { redirectUri = errorUri.Uri.ToString() });
}
// Validate redirect_uri if provided
@@ -191,7 +190,8 @@ public class OidcProviderController(
scope?.Split(' ') ?? [],
codeChallenge,
codeChallengeMethod,
nonce);
nonce
);
// Build the redirect URI with the authorization code
var redirectBuilder = new UriBuilder(redirectUri);
@@ -201,7 +201,7 @@ public class OidcProviderController(
redirectBuilder.Query = queryParams.ToString();
return Redirect(redirectBuilder.Uri.ToString());
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
}
catch (Exception ex)
{
@@ -307,7 +307,7 @@ public class OidcProviderController(
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.Challenge.Scopes;
var scopes = currentSession.Challenge?.Scopes ?? [];
var userInfo = new Dictionary<string, object>
{

View File

@@ -20,7 +20,6 @@ public class TokenResponse
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("id_token")]
public string? IdToken { get; set; }
}

View File

@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -37,12 +38,21 @@ public class OidcProviderService(
return resp.App ?? null;
}
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
{
var now = SystemClock.Instance.GetCurrentInstant();
return await db.AuthSessions
var queryable = db.AuthSessions
.Include(s => s.Challenge)
.AsQueryable();
if (withAccount)
queryable = queryable
.Include(s => s.Account)
.ThenInclude(a => a.Profile)
.Include(a => a.Account.Contacts)
.AsQueryable();
return await queryable
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
@@ -67,12 +77,12 @@ public class OidcProviderService(
{
if (string.IsNullOrEmpty(redirectUri))
return false;
var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production)
return true;
if (client?.OauthConfig?.RedirectUris == null)
return false;
@@ -114,7 +124,7 @@ public class OidcProviderService(
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
if (string.IsNullOrEmpty(allowedPath) ||
if (string.IsNullOrEmpty(allowedPath) ||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
{
return true;
@@ -133,6 +143,79 @@ public class OidcProviderService(
return false;
}
private string GenerateIdToken(
CustomApp client,
AuthSession session,
string? nonce = null,
IEnumerable<string>? scopes = null
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new(JwtRegisteredClaimNames.Aud, client.Slug),
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.Exp,
now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
.ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
};
// Add nonce if provided (required for implicit and hybrid flows)
if (!string.IsNullOrEmpty(nonce))
{
claims.Add(new Claim("nonce", nonce));
}
// Add email claim if email scope is requested
var scopesList = scopes?.ToList() ?? [];
if (scopesList.Contains("email"))
{
var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
if (contact is not null)
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
ClaimValueTypes.Boolean));
}
}
// Add profile claims if profile scope is requested
if (scopes != null && scopesList.Contains("profile"))
{
if (!string.IsNullOrEmpty(session.Account.Name))
claims.Add(new Claim("preferred_username", session.Account.Name));
if (!string.IsNullOrEmpty(session.Account.Nick))
claims.Add(new Claim("name", session.Account.Nick));
if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
}
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
new RsaSecurityKey(_options.GetRsaPrivateKey()),
SecurityAlgorithms.RsaSha256
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId,
string? authorizationCode = null,
@@ -148,24 +231,43 @@ public class OidcProviderService(
AuthSession session;
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
string? nonce = null;
List<string>? scopes = null;
if (authorizationCode != null)
{
// Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account was not found");
if (authCode == null)
throw new InvalidOperationException("Invalid authorization code");
// Load the session for the user
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
if (existingSession is null)
{
var account = await db.Accounts
.Where(a => a.Id == authCode.AccountId)
.Include(a => a.Profile)
.Include(a => a.Contacts)
.FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account not found");
session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
session.Account = account;
}
else
{
session = existingSession;
}
session = await auth.CreateSessionForOidcAsync(account, now, clientId);
scopes = authCode.Scopes;
nonce = authCode.Nonce;
}
else if (sessionId.HasValue)
{
// Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session");
throw new InvalidOperationException("Session not found");
// Verify the session is still valid
if (session.ExpiredAt < now)
@@ -179,13 +281,15 @@ public class OidcProviderService(
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate an access token
// Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
@@ -211,11 +315,10 @@ public class OidcProviderService(
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim("client_id", client.Id)
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
Audience = client.Id
Audience = client.Slug
};
// Try to use RSA signing if keys are available, fall back to HMAC
@@ -281,51 +384,6 @@ public class OidcProviderService(
return Convert.ToBase64String(session.Id.ToByteArray());
}
private static bool VerifyHashedSecret(string secret, string hashedSecret)
{
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
// For now, we'll do a simple comparison, but you should replace this with proper hashing
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
}
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
AuthSession session,
Guid clientId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null)
{
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var code = Guid.NewGuid().ToString("N");
// Update the session's last activity time
await db.AuthSessions.Where(s => s.Id == session.Id)
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = session.AccountId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
return code;
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
Guid userId,
@@ -355,7 +413,7 @@ public class OidcProviderService(
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
var cacheKey = $"auth:oidc-code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
@@ -369,7 +427,7 @@ public class OidcProviderService(
string? codeVerifier = null
)
{
var cacheKey = $"auth:code:{code}";
var cacheKey = $"auth:oidc-code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null)

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Pass.Wallet;
@@ -22,8 +23,9 @@ public class TokenAuthService(
/// then cache and return.
/// </summary>
/// <param name="token">Incoming token string</param>
/// <param name="ipAddress">Client IP address, for logging purposes</param>
/// <returns>(Valid, Session, Message)</returns>
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token)
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
{
try
{
@@ -32,6 +34,11 @@ public class TokenAuthService(
logger.LogWarning("AuthenticateTokenAsync: no token provided");
return (false, null, "No token provided.");
}
if (!string.IsNullOrEmpty(ipAddress))
{
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
}
// token fingerprint for correlation
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
@@ -70,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId,
session.AccountId,
session.Challenge.Scopes.Count,
session.Challenge?.Scopes.Count,
session.ExpiredAt
);
return (true, session, null);
@@ -103,11 +110,11 @@ public class TokenAuthService(
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
sessionId,
session.AccountId,
session.Challenge.ClientId,
session.Challenge?.ClientId,
session.AppId,
session.Challenge.Scopes.Count,
session.Challenge.IpAddress,
(session.Challenge.UserAgent ?? string.Empty).Length
session.Challenge?.Scopes.Count,
session.Challenge?.IpAddress,
(session.Challenge?.UserAgent ?? string.Empty).Length
);
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
@@ -136,7 +143,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId,
session.AccountId,
session.Challenge.ClientId
session.Challenge?.ClientId
);
return (true, session, null);
}

View File

@@ -169,15 +169,15 @@ function handleDeny() {
error_description: 'The user denied the authorization request',
state: state,
})
window.location.href = `${redirectUri}?${params}`
window.open(`${redirectUri}?${params}`, "_self")
}
function openTerms() {
window.open(clientInfo.value?.terms_of_service_uri || 'https://example.com/terms', '_blank')
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
}
function openPrivacy() {
window.open(clientInfo.value?.privacy_policy_uri || 'https://example.com/privacy', '_blank')
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
}
// Lifecycle

View File

@@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
}
var accountId = currentUser.Id!;
var deviceId = currentSession.Challenge.DeviceId!;
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
if (string.IsNullOrEmpty(deviceId))
{
@@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
return;
}
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext
{ KeepAliveInterval = TimeSpan.FromSeconds(60) });
var cts = new CancellationTokenSource();
var connectionKey = (accountId, deviceId);
@@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
}
catch (Exception ex)
{
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",
currentUser.Name,
currentUser.Id,
deviceId
);
}
finally
{
@@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
break;
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
}
}
catch (OperationCanceledException)
@@ -113,4 +119,4 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
}
}
}
}
}

View File

@@ -49,11 +49,18 @@ public class WebSocketService
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
{
if (!ActiveConnections.TryGetValue(key, out var data)) return;
data.Socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
reason ?? "Server just decided to disconnect.",
CancellationToken.None
);
try
{
data.Socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
reason ?? "Server just decided to disconnect.",
CancellationToken.None
);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, key.DeviceId);
}
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
}

View File

@@ -5,6 +5,7 @@ using DysonNetwork.Pusher.Services;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using WebSocketPacket = DysonNetwork.Pusher.Connection.WebSocketPacket;
namespace DysonNetwork.Pusher.Notification;
@@ -55,8 +56,6 @@ public class PushService
_ws = ws;
_queueService = queueService;
_logger = logger;
_logger.LogInformation("PushService initialized");
}
public async Task UnsubscribeDevice(string deviceId)
@@ -149,6 +148,12 @@ public class PushService
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
{
_ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket()
{
Type = "notifications.new",
Data = notification,
});
try
{
_logger.LogInformation(
@@ -162,7 +167,7 @@ public class PushService
.Where(s => s.AccountId == notification.AccountId)
.ToListAsync(cancellationToken);
if (!subscriptions.Any())
if (subscriptions.Count == 0)
{
_logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId);
return;
@@ -174,7 +179,7 @@ public class PushService
{
try
{
tasks.Add(SendPushNotificationAsync(subscription, notification, cancellationToken));
tasks.Add(SendPushNotificationAsync(subscription, notification));
}
catch (Exception ex)
{
@@ -254,17 +259,10 @@ public class PushService
});
}
// Fetch all subscribers once and enqueue to workers
var subscriptions = await _db.PushSubscriptions
.Where(s => accounts.Contains(s.AccountId))
.AsNoTracking()
.ToListAsync();
await DeliverPushNotification(notification);
}
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification,
CancellationToken cancellationToken)
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification)
{
try
{

View File

@@ -51,7 +51,6 @@ public class QueueBackgroundService(
}
else
{
await msg.ReplyAsync(cancellationToken: stoppingToken);
logger.LogWarning($"Invalid message format for {msg.Subject}");
}
}
@@ -92,8 +91,6 @@ public class QueueBackgroundService(
logger.LogWarning("Unknown message type: {MessageType}", message.Type);
break;
}
await rawMsg.ReplyAsync(cancellationToken: cancellationToken);
}
catch (Exception ex)
{
@@ -121,7 +118,7 @@ public class QueueBackgroundService(
{
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
var notification = JsonSerializer.Deserialize<Notification.Notification>(message.Data);
if (notification == null)
{

View File

@@ -134,7 +134,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
{
services.AddScoped<WebSocketService>();
services.AddSingleton<WebSocketService>();
services.AddScoped<EmailService>();
services.AddScoped<PushService>();

View File

@@ -33,7 +33,10 @@ public class DysonTokenAuthHandler(
AuthSession session;
try
{
session = await ValidateToken(tokenInfo.Token);
session = await ValidateToken(
tokenInfo.Token,
Request.HttpContext.Connection.RemoteIpAddress?.ToString()
);
}
catch (InvalidOperationException ex)
{
@@ -58,7 +61,7 @@ public class DysonTokenAuthHandler(
};
// Add scopes as claims
session.Challenge.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
session.Challenge?.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)
@@ -78,12 +81,15 @@ public class DysonTokenAuthHandler(
}
}
private async Task<AuthSession> ValidateToken(string token)
private async Task<AuthSession> ValidateToken(string token, string? ipAddress)
{
var resp = await auth.AuthenticateAsync(new AuthenticateRequest { Token = token });
var resp = await auth.AuthenticateAsync(new AuthenticateRequest
{
Token = token,
IpAddress = ipAddress
});
if (!resp.Valid) throw new InvalidOperationException(resp.Message);
if (resp.Session == null) throw new InvalidOperationException("Session not found.");
return resp.Session;
return resp.Session ?? throw new InvalidOperationException("Session not found.");
}
private static byte[] Base64UrlDecode(string base64Url)

View File

@@ -10,6 +10,8 @@ public abstract class ActionLogType
public const string PostUpdate = "posts.update";
public const string PostDelete = "posts.delete";
public const string PostReact = "posts.react";
public const string PostPin = "posts.pin";
public const string PostUnpin = "posts.unpin";
public const string MessageCreate = "messages.create";
public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete";
@@ -37,4 +39,4 @@ public abstract class ActionLogType
public const string ChatroomLeave = "chatrooms.leave";
public const string ChatroomKick = "chatrooms.kick";
public const string ChatroomAdjustRole = "chatrooms.role.edit";
}
}

View File

@@ -20,9 +20,7 @@ public static class KestrelConfiguration
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = maxRequestBodySize;
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
var configuredUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
if (!string.IsNullOrEmpty(configuredUrl)) return;

View File

@@ -70,6 +70,7 @@ service AuthService {
message AuthenticateRequest {
string token = 1;
optional google.protobuf.StringValue ip_address = 2;
}
message AuthenticateResponse {

View File

@@ -1,6 +1,7 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@@ -11,6 +12,7 @@ public class ActivityService(
AppDatabase db,
Publisher.PublisherService pub,
PostService ps,
RealmService rs,
DiscoveryService ds,
AccountService.AccountServiceClient accounts
)
@@ -63,7 +65,7 @@ public class ActivityService(
var posts = await GetAndProcessPosts(postsQuery);
posts = RankPosts(posts, take);
// Add posts to activities
activities.AddRange(posts.Select(post => post.ToActivity()));
@@ -123,28 +125,30 @@ public class ActivityService(
var filteredPublishers = await GetFilteredPublishers(filter, currentUser, userFriends);
var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
// Build and execute the posts query
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId);
var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
// Apply visibility filtering and execute
postsQuery = postsQuery
.FilterWithVisibility(
currentUser,
userFriends,
filter is null ? userPublishers : [],
currentUser,
userFriends,
filter is null ? userPublishers : [],
isListing: true)
.Take(take * 5);
// Get, process and rank posts
var posts = await GetAndProcessPosts(
postsQuery,
currentUser,
userFriends,
userPublishers,
postsQuery,
currentUser,
userFriends,
userPublishers,
trackViews: true);
posts = RankPosts(posts, take);
// Add posts to activities
activities.AddRange(posts.Select(post => post.ToActivity()));
@@ -197,7 +201,7 @@ public class ActivityService(
private async Task<Activity?> GetRealmDiscoveryActivity(int count = 5)
{
var realms = await ds.GetCommunityRealmAsync(null, count, 0, true);
return realms.Count > 0
return realms.Count > 0
? new DiscoveryActivity(realms.Select(x => new DiscoveryItem("realm", x)).ToList()).ToActivity()
: null;
}
@@ -206,7 +210,8 @@ public class ActivityService(
{
var popularPublishers = await GetPopularPublishers(count);
return popularPublishers.Count > 0
? new DiscoveryActivity(popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()).ToActivity()
? new DiscoveryActivity(popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList())
.ToActivity()
: null;
}
@@ -252,11 +257,11 @@ public class ActivityService(
var postsId = posts.Select(e => e.Id).ToList();
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
{
post.ReactionsCount = reactionMaps.GetValueOrDefault(post.Id, new Dictionary<string, int>());
if (trackViews && currentUser != null)
{
await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString());
@@ -266,7 +271,11 @@ public class ActivityService(
return posts;
}
private IQueryable<Post.Post> BuildPostsQuery(Instant? cursor, List<Guid>? filteredPublishersId = null)
private IQueryable<Post.Post> BuildPostsQuery(
Instant? cursor,
List<Guid>? filteredPublishersId = null,
List<Guid>? userRealms = null
)
{
var query = db.Posts
.Include(e => e.RepliedPost)
@@ -280,16 +289,19 @@ public class ActivityService(
.AsQueryable();
if (filteredPublishersId != null && filteredPublishersId.Any())
{
query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
}
if (userRealms == null)
query = query.Where(p => p.Realm == null || p.Realm.IsPublic);
else
query = query.Where(p =>
p.Realm == null || p.Realm.IsPublic || p.RealmId == null || userRealms.Contains(p.RealmId.Value));
return query;
}
private async Task<List<Publisher.Publisher>?> GetFilteredPublishers(
string? filter,
Account currentUser,
string? filter,
Account currentUser,
List<Guid> userFriends)
{
return filter?.ToLower() switch
@@ -309,4 +321,4 @@ public class ActivityService(
var postCount = posts.Count;
return score + postCount;
}
}
}

View File

@@ -35,6 +35,7 @@ public class AppDatabase(
public DbSet<PostCategory> PostCategories { get; set; } = null!;
public DbSet<PostCollection> PostCollections { get; set; } = null!;
public DbSet<PostFeaturedRecord> PostFeaturedRecords { get; set; } = null!;
public DbSet<PostCategorySubscription> PostCategorySubscriptions { get; set; } = null!;
public DbSet<Poll.Poll> Polls { get; set; } = null!;
public DbSet<Poll.PollQuestion> PollQuestions { get; set; } = null!;

View File

@@ -150,7 +150,7 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid accountId)
{
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var room = await db.ChatRooms
@@ -968,25 +968,31 @@ public class ChatRoomController(
private async Task _SendInviteNotify(ChatMember member, Account sender)
{
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
CultureService.SetCultureInfo(account);
string title = localizer["ChatInviteTitle"];
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
? localizer["ChatInviteDirectBody", sender.Nick]
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
CultureService.SetCultureInfo(member.Account!.Language);
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = member.Account.Id.ToString(),
UserId = account.Id,
Notification = new PushNotification
{
Topic = "invites.chats",
Title = title,
Body = body,
IsSavable = true
IsSavable = true,
Meta = GrpcTypeHelper.ConvertObjectToByteString(new
{
room_id = member.ChatRoomId
})
}
}
);
}
}
}

View File

@@ -69,7 +69,8 @@ public partial class ChatService(
dbMessage,
dbMessage.Sender,
dbMessage.ChatRoom,
WebSocketPacketType.MessageUpdate
WebSocketPacketType.MessageUpdate,
notify: false
);
}
}
@@ -87,8 +88,7 @@ public partial class ChatService(
/// <param name="message">The message to process</param>
/// <param name="webReader">The web reader service</param>
/// <returns>The message with link previews added to its meta data</returns>
public async Task<Message> PreviewMessageLinkAsync(Message message,
WebReader.WebReaderService? webReader = null)
public async Task<Message> PreviewMessageLinkAsync(Message message, WebReaderService? webReader = null)
{
if (string.IsNullOrEmpty(message.Content))
return message;
@@ -110,8 +110,7 @@ public partial class ChatService(
}
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
webReader ??= scopeFactory.CreateScope().ServiceProvider
.GetRequiredService<WebReader.WebReaderService>();
webReader ??= scopeFactory.CreateScope().ServiceProvider.GetRequiredService<WebReaderService>();
// Process up to 3 links to avoid excessive processing
var processedLinks = 0;
@@ -195,7 +194,8 @@ public partial class ChatService(
Message message,
ChatMember sender,
ChatRoom room,
string type = WebSocketPacketType.MessageNew
string type = WebSocketPacketType.MessageNew,
bool notify = true
)
{
message.Sender = sender;
@@ -205,11 +205,29 @@ public partial class ChatService(
var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
var members = await scopedCrs.ListRoomMembers(room.Id);
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);
if (!notify)
{
logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts.");
return;
}
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
var members = await scopedCrs.ListRoomMembers(room.Id);
if (sender.Account is null)
sender = await scopedCrs.LoadMemberAccount(sender);
if (sender.Account is null)
@@ -273,17 +291,6 @@ public partial class ChatService(
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
@@ -524,25 +531,51 @@ public partial class ChatService(
})
.ToListAsync();
var changesMembers = changes
// Get messages that need member data
var messagesNeedingSenders = changes
.Where(c => c.Message != null)
.Select(c => c.Message!.Sender)
.Select(c => c.Message!)
.ToList();
// If no messages need senders, return with the latest timestamp from changes
if (messagesNeedingSenders.Count <= 0)
{
var latestTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = latestTimestamp
};
}
// Load member accounts for messages that need them
var changesMembers = messagesNeedingSenders
.Select(m => m.Sender)
.DistinctBy(x => x.Id)
.ToList();
changesMembers = await crs.LoadMemberAccounts(changesMembers);
foreach (var change in changes)
// Update sender information for messages that have it
foreach (var message in messagesNeedingSenders)
{
if (change.Message == null) continue;
var sender = changesMembers.FirstOrDefault(x => x.Id == change.Message.SenderId);
var sender = changesMembers.FirstOrDefault(x => x.Id == message.SenderId);
if (sender is not null)
change.Message.Sender = sender;
message.Sender = sender;
}
// Use the latest timestamp from changes, or current time if no changes
var latestChangeTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant();
return new SyncResponse
{
Changes = changes,
CurrentTimestamp = SystemClock.Instance.GetCurrentInstant()
CurrentTimestamp = latestChangeTimestamp
};
}
@@ -654,4 +687,4 @@ public class SyncResponse
{
public List<MessageChange> Changes { get; set; } = [];
public Instant CurrentTimestamp { get; set; }
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="post" class="container max-w-5xl mx-auto mt-4">
<n-grid cols="1 l:5" responsive="screen" :x-gap="16">
<n-grid cols="1 l:5" responsive="screen" :x-gap="16" :y-gap="16">
<n-gi span="3">
<post-item :item="post" />
</n-gi>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPostPin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "pin_mode",
table: "posts",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pin_mode",
table: "posts");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPostCategoryTagSubscription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "post_category_subscriptions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
category_id = table.Column<Guid>(type: "uuid", nullable: true),
tag_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_post_category_subscriptions", x => x.id);
table.ForeignKey(
name: "fk_post_category_subscriptions_post_categories_category_id",
column: x => x.category_id,
principalTable: "post_categories",
principalColumn: "id");
table.ForeignKey(
name: "fk_post_category_subscriptions_post_tags_tag_id",
column: x => x.tag_id,
principalTable: "post_tags",
principalColumn: "id");
});
migrationBuilder.CreateIndex(
name: "ix_post_category_subscriptions_category_id",
table: "post_category_subscriptions",
column: "category_id");
migrationBuilder.CreateIndex(
name: "ix_post_category_subscriptions_tag_id",
table: "post_category_subscriptions",
column: "tag_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "post_category_subscriptions");
}
}
}

View File

@@ -566,6 +566,10 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<int?>("PinMode")
.HasColumnType("integer")
.HasColumnName("pin_mode");
b.Property<Instant?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("published_at");
@@ -691,6 +695,49 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("post_categories", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategorySubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("CategoryId")
.HasColumnType("uuid")
.HasColumnName("category_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid?>("TagId")
.HasColumnType("uuid")
.HasColumnName("tag_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_post_category_subscriptions");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_post_category_subscriptions_category_id");
b.HasIndex("TagId")
.HasDatabaseName("ix_post_category_subscriptions_tag_id");
b.ToTable("post_category_subscriptions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
{
b.Property<Guid>("Id")
@@ -1723,6 +1770,23 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("RepliedPost");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategorySubscription", b =>
{
b.HasOne("DysonNetwork.Sphere.Post.PostCategory", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.HasConstraintName("fk_post_category_subscriptions_post_categories_category_id");
b.HasOne("DysonNetwork.Sphere.Post.PostTag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_post_category_subscriptions_post_tags_tag_id");
b.Navigation("Category");
b.Navigation("Tag");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
{
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")

View File

@@ -51,17 +51,15 @@ public class PostPageData(
.Include(e => e.Categories)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post == null) return new Dictionary<string, object?>();
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);
var og = OpenGraph.MakeGraph(
title: post.Title ?? $"Post from {post.Publisher.Name}",
type: "article",
image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true",
url: $"{_siteUrl}/@{slug}",
description: post.Description ?? post.Content?[..80] ?? "Posted with some media",
description: post.Description ?? (post.Content?.Length > 80 ? post.Content?[..80] : post.Content) ?? "Posted with some media",
siteName: "Solar Network"
);

View File

@@ -23,6 +23,13 @@ public enum PostVisibility
Private
}
public enum PostPinMode
{
PublisherPage,
RealmPage,
ReplyPage,
}
public class Post : ModelBase, IIdentifiedResource, IActivity
{
public Guid Id { get; set; }
@@ -37,6 +44,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
public string? Content { get; set; }
public PostType Type { get; set; }
public PostPinMode? PinMode { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
@@ -97,7 +105,7 @@ public class PostTag : ModelBase
[MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
[NotMapped] public int? Usage { get; set; }
}
@@ -107,10 +115,21 @@ public class PostCategory : ModelBase
[MaxLength(128)] public string Slug { get; set; } = null!;
[MaxLength(256)] public string? Name { get; set; }
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
[NotMapped] public int? Usage { get; set; }
}
public class PostCategorySubscription : ModelBase
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public Guid? CategoryId { get; set; }
public PostCategory? Category { get; set; }
public Guid? TagId { get; set; }
public PostTag? Tag { get; set; }
}
public class PostCollection : ModelBase
{
public Guid Id { get; set; }

View File

@@ -1,3 +1,6 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -33,12 +36,12 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
var totalCount = await categoriesQuery.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
// Get categories with their post counts in a single query
var categories = await categoriesQuery
.Skip(offset)
.Take(take)
.Select(c => new
.Select(c => new
{
Category = c,
PostCount = c.Posts.Count
@@ -46,15 +49,13 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
.ToListAsync();
// Project results back to the original type and set the Usage property
var result = categories.Select(x =>
var result = categories.Select(x =>
{
x.Category.Usage = x.PostCount;
return x.Category;
}).ToList();
return Ok(result);
return Ok(categories);
}
[HttpGet("tags")]
@@ -83,12 +84,12 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
var totalCount = await tagsQuery.CountAsync();
Response.Headers.Append("X-Total", totalCount.ToString());
// Get tags with their post counts in a single query
var tags = await tagsQuery
.Skip(offset)
.Take(take)
.Select(t => new
.Select(t => new
{
Tag = t,
PostCount = t.Posts.Count
@@ -96,7 +97,7 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
.ToListAsync();
// Project results back to the original type and set the Usage property
var result = tags.Select(x =>
var result = tags.Select(x =>
{
x.Tag.Usage = x.PostCount;
return x.Tag;
@@ -122,4 +123,162 @@ public class PostCategoryController(AppDatabase db) : ControllerBase
return NotFound();
return Ok(tag);
}
}
[HttpPost("categories/{slug}/subscribe")]
[Authorize]
public async Task<ActionResult<PostCategorySubscription>> SubscribeCategory(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var category = await db.PostCategories.FirstOrDefaultAsync(c => c.Slug == slug);
if (category == null)
{
return NotFound("Category not found.");
}
var existingSubscription = await db.PostCategorySubscriptions
.FirstOrDefaultAsync(s => s.CategoryId == category.Id && s.AccountId == accountId);
if (existingSubscription != null)
return Ok(existingSubscription);
var subscription = new PostCategorySubscription
{
AccountId = accountId,
CategoryId = category.Id
};
db.PostCategorySubscriptions.Add(subscription);
await db.SaveChangesAsync();
return CreatedAtAction(nameof(GetCategorySubscription), new { slug }, subscription);
}
[HttpPost("categories/{slug}/unsubscribe")]
[Authorize]
public async Task<IActionResult> UnsubscribeCategory(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var category = await db.PostCategories.FirstOrDefaultAsync(c => c.Slug == slug);
if (category == null)
return NotFound("Category not found.");
var subscription = await db.PostCategorySubscriptions
.FirstOrDefaultAsync(s => s.CategoryId == category.Id && s.AccountId == accountId);
if (subscription == null)
return NoContent();
db.PostCategorySubscriptions.Remove(subscription);
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("categories/{slug}/subscription")]
[Authorize]
public async Task<ActionResult<PostCategorySubscription>> GetCategorySubscription(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var category = await db.PostCategories.FirstOrDefaultAsync(c => c.Slug == slug);
if (category == null)
return NotFound("Category not found.");
var subscription = await db.PostCategorySubscriptions
.FirstOrDefaultAsync(s => s.CategoryId == category.Id && s.AccountId == accountId);
if (subscription == null)
return NotFound("Subscription not found.");
return Ok(subscription);
}
[HttpPost("tags/{slug}/subscribe")]
[Authorize]
public async Task<ActionResult<PostCategorySubscription>> SubscribeTag(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var tag = await db.PostTags.FirstOrDefaultAsync(t => t.Slug == slug);
if (tag == null)
{
return NotFound("Tag not found.");
}
var existingSubscription = await db.PostCategorySubscriptions
.FirstOrDefaultAsync(s => s.TagId == tag.Id && s.AccountId == accountId);
if (existingSubscription != null)
{
return Ok(existingSubscription);
}
var subscription = new PostCategorySubscription
{
AccountId = accountId,
TagId = tag.Id
};
db.PostCategorySubscriptions.Add(subscription);
await db.SaveChangesAsync();
return CreatedAtAction(nameof(GetTagSubscription), new { slug }, subscription);
}
[HttpPost("tags/{slug}/unsubscribe")]
[Authorize]
public async Task<IActionResult> UnsubscribeTag(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var tag = await db.PostTags.FirstOrDefaultAsync(t => t.Slug == slug);
if (tag == null)
{
return NotFound("Tag not found.");
}
var subscription = await db.PostCategorySubscriptions
.FirstOrDefaultAsync(s => s.TagId == tag.Id && s.AccountId == accountId);
if (subscription == null)
{
return NoContent();
}
db.PostCategorySubscriptions.Remove(subscription);
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("tags/{slug}/subscription")]
[Authorize]
public async Task<ActionResult<PostCategorySubscription>> GetTagSubscription(string slug)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var tag = await db.PostTags.FirstOrDefaultAsync(t => t.Slug == slug);
if (tag == null)
{
return NotFound("Tag not found.");
}
var subscription = await db.PostCategorySubscriptions
.FirstOrDefaultAsync(s => s.TagId == tag.Id && s.AccountId == accountId);
if (subscription == null)
{
return NotFound("Subscription not found.");
}
return Ok(subscription);
}
}

View File

@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Swashbuckle.AspNetCore.Annotations;
using PublisherMemberRole = DysonNetwork.Sphere.Publisher.PublisherMemberRole;
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
namespace DysonNetwork.Sphere.Post;
@@ -53,6 +54,7 @@ public class PostController(
/// <param name="queryVector">If true, uses vector search with the query term. If false, performs a simple ILIKE search.</param>
/// <param name="onlyMedia">If true, only returns posts that have attachments.</param>
/// <param name="shuffle">If true, returns posts in random order. If false, orders by published/created date (newest first).</param>
/// <param name="pinned">If true, returns posts that pinned. If false, returns posts that are not pinned. If null, returns all posts.</param>
/// <returns>
/// Returns an ActionResult containing a list of Post objects that match the specified criteria.
/// Includes an X-Total header with the total count of matching posts before pagination.
@@ -63,14 +65,14 @@ public class PostController(
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerOperation(
Summary = "Retrieves a paginated list of posts",
Description = "Gets posts with various filtering and sorting options. Supports pagination and advanced search capabilities.",
Description =
"Gets posts with various filtering and sorting options. Supports pagination and advanced search capabilities.",
OperationId = "ListPosts",
Tags = ["Posts"]
)]
[SwaggerResponse(StatusCodes.Status200OK, "Successfully retrieved the list of posts", typeof(List<Post>))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request parameters")]
public async Task<ActionResult<List<Post>>> ListPosts(
[FromQuery(Name = "replies")] bool? includeReplies,
[FromQuery] int offset = 0,
[FromQuery] int take = 20,
[FromQuery(Name = "pub")] string? pubName = null,
@@ -81,7 +83,9 @@ public class PostController(
[FromQuery(Name = "query")] string? queryTerm = null,
[FromQuery(Name = "vector")] bool queryVector = false,
[FromQuery(Name = "media")] bool onlyMedia = false,
[FromQuery(Name = "shuffle")] bool shuffle = false
[FromQuery(Name = "shuffle")] bool shuffle = false,
[FromQuery(Name = "replies")] bool? includeReplies = null,
[FromQuery(Name = "pinned")] bool? pinned = null
)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
@@ -95,7 +99,9 @@ public class PostController(
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId);
var userRealms = currentUser is null ? [] : await rs.GetUserRealms(accountId);
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);
@@ -103,6 +109,9 @@ public class PostController(
var query = db.Posts
.Include(e => e.Categories)
.Include(e => e.Tags)
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Realm)
.AsQueryable();
if (publisher != null)
query = query.Where(p => p.PublisherId == publisher.Id);
@@ -116,7 +125,27 @@ public class PostController(
query = query.Where(p => p.Tags.Any(c => tags.Contains(c.Slug)));
if (onlyMedia)
query = query.Where(e => e.Attachments.Count > 0);
if (realm == null)
query = query.Where(p =>
p.RealmId == null || p.Realm == null || userRealms.Contains(p.RealmId.Value) || p.Realm.IsPublic);
switch (pinned)
{
case true when realm != null:
query = query.Where(p => p.PinMode == PostPinMode.RealmPage);
break;
case true when publisher != null:
query = query.Where(p => p.PinMode == PostPinMode.PublisherPage);
break;
case true:
return BadRequest(
"You need pass extra realm or publisher params in order to filter with pinned posts.");
case false:
query = query.Where(p => p.PinMode == null);
break;
}
query = includeReplies switch
{
false => query.Where(e => e.RepliedPostId == null),
@@ -147,9 +176,6 @@ public class PostController(
: query.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt);
var posts = await query
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Realm)
.Skip(offset)
.Take(take)
.ToListAsync();
@@ -188,9 +214,6 @@ public class PostController(
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);
}
@@ -222,9 +245,6 @@ public class PostController(
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);
}
@@ -278,12 +298,36 @@ public class PostController(
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
post = await ps.LoadPostInfo(post, currentUser);
post = await ps.LoadPostInfo(post, currentUser, true);
// Track view - use the account ID as viewer ID if user is logged in
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
return Ok(post);
}
return await ps.LoadPostInfo(post);
[HttpGet("{id:guid}/replies/pinned")]
public async Task<ActionResult<List<Post>>> ListPinnedReplies(Guid id)
{
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 now = SystemClock.Instance.GetCurrentInstant();
var posts = await db.Posts
.Where(e => e.RepliedPostId == id && e.PinMode == PostPinMode.ReplyPage)
.OrderByDescending(p => p.CreatedAt)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.ToListAsync();
if (posts is null) return NotFound();
posts = await ps.LoadPostInfo(posts, currentUser);
return Ok(posts);
}
[HttpGet("{id:guid}/replies")]
@@ -535,6 +579,106 @@ public class PostController(
return Ok(reaction);
}
public class PostPinRequest
{
[Required] public PostPinMode Mode { get; set; }
}
[HttpPost("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<Post>> PinPost(Guid id, [FromBody] PostPinRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (request.Mode == PostPinMode.RealmPage && post.RealmId != null)
{
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator))
return StatusCode(403, "You are not a moderator of this realm");
}
try
{
await ps.PinPostAsync(post, currentUser, request.Mode);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = ActionLogType.PostPin,
Meta =
{
{ "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) },
{ "mode", Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString()) }
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(post);
}
[HttpDelete("{id:guid}/pin")]
[Authorize]
public async Task<ActionResult<Post>> UnpinPost(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.Include(e => e.RepliedPost)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher");
if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null })
{
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator))
return StatusCode(403, "You are not a moderator of this realm");
}
try
{
await ps.UnpinPostAsync(post, currentUser);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
_ = als.CreateActionLogAsync(new CreateActionLogRequest
{
Action = ActionLogType.PostUnpin,
Meta =
{
{ "post_id", Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString()) }
},
AccountId = currentUser.Id.ToString(),
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(post);
}
[HttpPatch("{id:guid}")]
public async Task<ActionResult<Post>> UpdatePost(
Guid id,

View File

@@ -25,6 +25,7 @@ public partial class PostService(
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
PollService polls,
Publisher.PublisherService ps,
WebReaderService reader
)
{
@@ -418,6 +419,56 @@ public partial class PostService(
}
}
public async Task<Post> PinPostAsync(Post post, Account currentUser, PostPinMode pinMode)
{
var accountId = Guid.Parse(currentUser.Id);
if (post.RepliedPostId != null)
{
if (pinMode != PostPinMode.ReplyPage) throw new InvalidOperationException("Replies can only be pinned in the reply page.");
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors of original post can pin replies.");
post.PinMode = pinMode;
}
else
{
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors can pin replies.");
post.PinMode = pinMode;
}
db.Update(post);
await db.SaveChangesAsync();
return post;
}
public async Task<Post> UnpinPostAsync(Post post, Account currentUser)
{
var accountId = Guid.Parse(currentUser.Id);
if (post.RepliedPostId != null)
{
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors of original post can unpin replies.");
}
else
{
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Publisher.PublisherMemberRole.Editor))
throw new InvalidOperationException("Only editors can unpin posts.");
}
post.PinMode = null;
db.Update(post);
await db.SaveChangesAsync();
return post;
}
/// <summary>
/// Calculate the total number of votes for a post.
/// This function helps you save the new reactions.
@@ -770,7 +821,6 @@ public partial class PostService(
var reactSocialPoints = await db.PostReactions
.Include(e => e.Post)
.Where(e => e.Post.Visibility == PostVisibility.Public)
.Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd)
.Where(e => e.Post.CreatedAt >= periodStart && e.Post.CreatedAt < periodEnd)
.GroupBy(e => e.PostId)
.Select(e => new
@@ -784,16 +834,27 @@ public partial class PostService(
featuredIds = reactSocialPoints.Select(e => e.Key).ToList();
await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(24));
await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(4));
// Create featured record
var records = reactSocialPoints.Select(e => new PostFeaturedRecord
var existingFeaturedPostIds = await db.PostFeaturedRecords
.Where(r => featuredIds.Contains(r.PostId))
.Select(r => r.PostId)
.ToListAsync();
var records = reactSocialPoints
.Where(p => !existingFeaturedPostIds.Contains(p.Key))
.Select(e => new PostFeaturedRecord
{
PostId = e.Key,
SocialCredits = e.Value
}).ToList();
if (records.Any())
{
PostId = e.Key,
SocialCredits = e.Value
}).ToList();
db.PostFeaturedRecords.AddRange(records);
await db.SaveChangesAsync();
db.PostFeaturedRecords.AddRange(records);
await db.SaveChangesAsync();
}
}
var posts = await db.Posts

View File

@@ -55,14 +55,7 @@ public class PublisherSubscriptionService(
return 0;
if (post.Visibility != PostVisibility.Public)
return 0;
var subscribers = await db.PublisherSubscriptions
.Where(p => p.PublisherId == post.PublisherId &&
p.Status == PublisherSubscriptionStatus.Active)
.ToListAsync();
if (subscribers.Count == 0)
return 0;
// Create notification data
var (title, message) = ps.ChopPostForNotification(post);
@@ -73,9 +66,38 @@ public class PublisherSubscriptionService(
{ "publisher_id", post.Publisher.Id.ToString() }
};
// Gather subscribers
var subscribers = await db.PublisherSubscriptions
.Where(p => p.PublisherId == post.PublisherId &&
p.Status == PublisherSubscriptionStatus.Active)
.ToListAsync();
if (subscribers.Count == 0)
return 0;
List<PostCategorySubscription> categorySubscribers = [];
if (post.Categories.Count > 0)
{
var categoryIds = post.Categories.Select(x => x.Id).ToList();
var subs = await db.PostCategorySubscriptions
.Where(s => s.CategoryId != null && categoryIds.Contains(s.CategoryId.Value))
.ToListAsync();
categorySubscribers.AddRange(subs);
}
if (post.Tags.Count > 0)
{
var tagIds = post.Tags.Select(x => x.Id).ToList();
var subs = await db.PostCategorySubscriptions
.Where(s => s.TagId != null && tagIds.Contains(s.TagId.Value))
.ToListAsync();
categorySubscribers.AddRange(subs);
}
List<string> requestAccountIds = [];
requestAccountIds.AddRange(subscribers.Select(x => x.AccountId.ToString()));
requestAccountIds.AddRange(categorySubscribers.Select(x => x.AccountId.ToString()));
var queryRequest = new GetAccountBatchRequest();
queryRequest.Id.AddRange(subscribers.DistinctBy(s => s.AccountId).Select(m => m.AccountId.ToString()));
queryRequest.Id.AddRange(requestAccountIds.Distinct());
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
// Notify each subscriber

View File

@@ -245,14 +245,14 @@ public class RealmController(
members.Select(m => m.AccountId).ToList()
);
members = members
.Select(m =>
{
m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null;
return m;
})
.OrderByDescending(m => m.Status?.IsOnline ?? false)
.ToList();
members = members
.Select(m =>
{
m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null;
return m;
})
.OrderByDescending(m => m.Status?.IsOnline ?? false)
.ToList();
var total = members.Count;
Response.Headers.Append("X-Total", total.ToString());
@@ -260,7 +260,7 @@ public class RealmController(
var result = members.Skip(offset).Take(take).ToList();
members = await rs.LoadMemberAccounts(result);
return Ok(members.Where(m => m.Account is not null).ToList());
}
else
@@ -742,4 +742,4 @@ public class RealmController(
return NoContent();
}
}
}

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Shared;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Localization;
@@ -12,9 +13,31 @@ public class RealmService(
PusherService.PusherServiceClient pusher,
AccountService.AccountServiceClient accounts,
IStringLocalizer<NotificationResource> localizer,
AccountClientHelper accountsHelper
AccountClientHelper accountsHelper,
ICacheService cache
)
{
private const string CacheKeyPrefix = "account:realms:";
public async Task<List<Guid>> GetUserRealms(Guid accountId)
{
var cacheKey = $"{CacheKeyPrefix}{accountId}";
var (found, cachedRealms) = await cache.GetAsyncWithStatus<List<Guid>>(cacheKey);
if (found && cachedRealms != null)
return cachedRealms;
var realms = await db.RealmMembers
.Include(m => m.Realm)
.Where(m => m.AccountId == accountId)
.Select(m => m.Realm!.Id)
.ToListAsync();
// Cache the result for 5 minutes
await cache.SetAsync(cacheKey, realms, TimeSpan.FromMinutes(5));
return realms;
}
public async Task SendInviteNotify(RealmMember member)
{
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });

View File

@@ -31,7 +31,7 @@ public class StickerController(
return NotFound("Sticker pack not found");
var accountId = Guid.Parse(currentUser.Id);
if (!await ps.IsMemberWithRole(accountId, pack.PublisherId, requiredRole))
if (!await ps.IsMemberWithRole(pack.PublisherId, accountId, requiredRole))
return StatusCode(403, "You are not a member of this publisher");
return Ok();

View File

@@ -235,15 +235,9 @@ public class WebFeedPublicController(
var accountId = Guid.Parse(currentUser.Id);
// Get IDs of already subscribed feeds
var subscribedFeedIds = await db.WebFeedSubscriptions
.Where(s => s.AccountId == accountId)
.Select(s => s.FeedId)
.ToListAsync();
var feedsQuery = db.WebFeeds
.Include(f => f.Publisher)
.Where(f => !subscribedFeedIds.Contains(f.Id))
.OrderByDescending(f => f.CreatedAt)
.AsQueryable();
// Apply search filter if query is provided
@@ -256,9 +250,6 @@ public class WebFeedPublicController(
);
}
// Order by most recently created first
feedsQuery = feedsQuery.OrderByDescending(f => f.CreatedAt);
var totalCount = await feedsQuery.CountAsync();
var feeds = await feedsQuery
.Skip(offset)

View File

@@ -146,6 +146,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATusDiskStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F1c_003F21999acd_003FTusDiskStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUri_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5d2c480da9be415dab9be535bb6d08713cc00_003Fd0_003Fffc36a51_003FUri_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationContext_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F6b_003F741ceebe_003FValidationContext_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketAcceptContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F16e10e91b7834a87b2f3f4f30bfada3ee000_003Fd0_003F44ef97dc_003FWebSocketAcceptContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketCloseStatus_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F82dcad099d814e3facee3a7c5e19928a3ae00_003F67_003F9e63fab4_003FWebSocketCloseStatus_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue">&lt;AssemblyExplorer&gt;
&lt;Assembly Path="/opt/homebrew/Cellar/dotnet/9.0.6/libexec/packs/Microsoft.AspNetCore.App.Ref/9.0.6/ref/net9.0/Microsoft.AspNetCore.RateLimiting.dll" /&gt;