Compare commits

...

16 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
18 changed files with 224 additions and 150 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")]
@@ -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);
@@ -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) &&
@@ -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
{
@@ -33,6 +35,11 @@ public class TokenAuthService(
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)));
var tokenFp = tokenHash[..8];
@@ -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

@@ -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))
{
@@ -67,7 +67,11 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
catch (Exception ex)
{
logger.LogError(ex,
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly");
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly",
currentUser.Name,
currentUser.Id,
deviceId
);
}
finally
{

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

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

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,23 +968,29 @@ 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,18 +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

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>

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

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