Compare commits

...

12 Commits

18 changed files with 568 additions and 348 deletions

View File

@@ -80,7 +80,7 @@ public class AccountCurrentController(
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public UsernameColor? UsernameColor { get; set; }
public Shared.Models.UsernameColor? UsernameColor { get; set; }
public Instant? Birthday { get; set; }
public List<ProfileLink>? Links { get; set; }
@@ -933,4 +933,4 @@ public class AccountCurrentController(
.ToListAsync();
return Ok(records);
}
}
}

View File

@@ -160,6 +160,26 @@ public class AccountServiceGrpc(
return response;
}
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
{
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
.Include(a => a.Profile)
.ToListAsync();
var perks = await subscriptions.GetPerkSubscriptionsAsync(
accounts.Select(x => x.Id).ToList()
);
foreach (var account in accounts)
if (perks.TryGetValue(account.Id, out var perk))
account.PerkSubscription = perk?.ToReference();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context)
{

View File

@@ -197,7 +197,8 @@ public class SubscriptionGiftController(
if (currentUser.Profile.Level < MinimumAccountLevel)
{
return StatusCode(403, "Account level must be at least 60 to purchase a gift.");
if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
}
Duration? giftDuration = null;

View File

@@ -250,6 +250,14 @@ public class SubscriptionService(
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
if (profile is null) throw new InvalidOperationException("Account must have a profile");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
}
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
@@ -684,6 +692,9 @@ public class SubscriptionService(
if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired.");
if (gift.GifterId == redeemer.Id)
throw new InvalidOperationException("You cannot redeem your own gift.");
// Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
throw new InvalidOperationException("This gift is not intended for you.");

View File

@@ -148,6 +148,32 @@ public class UsernameColor
public string? Value { get; set; } // e.g. "red" or "#ff6600"
public string? Direction { get; set; } // e.g. "to right"
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
public Proto.UsernameColor ToProtoValue()
{
var proto = new Proto.UsernameColor
{
Type = Type,
Value = Value,
Direction = Direction,
};
if (Colors is not null)
{
proto.Colors.AddRange(Colors);
}
return proto;
}
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
{
return new UsernameColor
{
Type = proto.Type,
Value = proto.Value,
Direction = proto.Direction,
Colors = proto.Colors?.ToList()
};
}
}
public class SnAccountProfile : ModelBase, IIdentifiedResource
@@ -218,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(),
ActiveBadge = ActiveBadge?.ToProtoValue(),
UsernameColor = UsernameColor?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
@@ -247,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId),
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};

View File

@@ -123,7 +123,7 @@ public class SnPostCategorySubscription : ModelBase
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public Guid? CategoryId { get; set; }
public SnPostCategory? Category { get; set; }
public Guid? TagId { get; set; }
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; }
[NotMapped] public SnAccount? Account { get; set; }
}
public class SnPostAward : ModelBase
@@ -176,7 +177,7 @@ public class SnPostAward : ModelBase
public decimal Amount { get; set; }
public PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
public Guid PostId { get; set; }
[JsonIgnore] public SnPost Post { get; set; } = null!;
public Guid AccountId { get; set; }

View File

@@ -14,232 +14,240 @@ import 'wallet.proto';
// Account represents a user account in the system
message Account {
string id = 1;
string name = 2;
string nick = 3;
string language = 4;
string region = 18;
google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6;
string id = 1;
string name = 2;
string nick = 3;
string language = 4;
string region = 18;
google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6;
AccountProfile profile = 7;
optional SubscriptionReferenceObject perk_subscription = 16;
repeated AccountContact contacts = 8;
repeated AccountBadge badges = 9;
repeated AccountAuthFactor auth_factors = 10;
repeated AccountConnection connections = 11;
repeated Relationship outgoing_relationships = 12;
repeated Relationship incoming_relationships = 13;
AccountProfile profile = 7;
optional SubscriptionReferenceObject perk_subscription = 16;
repeated AccountContact contacts = 8;
repeated AccountBadge badges = 9;
repeated AccountAuthFactor auth_factors = 10;
repeated AccountConnection connections = 11;
repeated Relationship outgoing_relationships = 12;
repeated Relationship incoming_relationships = 13;
google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15;
google.protobuf.StringValue automated_id = 17;
google.protobuf.Timestamp created_at = 14;
google.protobuf.Timestamp updated_at = 15;
google.protobuf.StringValue automated_id = 17;
}
// Enum for status attitude
enum StatusAttitude {
STATUS_ATTITUDE_UNSPECIFIED = 0;
POSITIVE = 1;
NEGATIVE = 2;
NEUTRAL = 3;
STATUS_ATTITUDE_UNSPECIFIED = 0;
POSITIVE = 1;
NEGATIVE = 2;
NEUTRAL = 3;
}
// AccountStatus represents the status of an account
message AccountStatus {
string id = 1;
StatusAttitude attitude = 2;
bool is_online = 3;
bool is_customized = 4;
bool is_invisible = 5;
bool is_not_disturb = 6;
google.protobuf.StringValue label = 7;
google.protobuf.Timestamp cleared_at = 8;
string account_id = 9;
bytes meta = 10;
string id = 1;
StatusAttitude attitude = 2;
bool is_online = 3;
bool is_customized = 4;
bool is_invisible = 5;
bool is_not_disturb = 6;
google.protobuf.StringValue label = 7;
google.protobuf.Timestamp cleared_at = 8;
string account_id = 9;
bytes meta = 10;
}
message UsernameColor {
string type = 1;
google.protobuf.StringValue value = 2;
google.protobuf.StringValue direction = 3;
repeated string colors = 4;
}
// Profile contains detailed information about a user
message AccountProfile {
string id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue middle_name = 3;
google.protobuf.StringValue last_name = 4;
google.protobuf.StringValue bio = 5;
google.protobuf.StringValue gender = 6;
google.protobuf.StringValue pronouns = 7;
google.protobuf.StringValue time_zone = 8;
google.protobuf.StringValue location = 9;
google.protobuf.Timestamp birthday = 10;
google.protobuf.Timestamp last_seen_at = 11;
string id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue middle_name = 3;
google.protobuf.StringValue last_name = 4;
google.protobuf.StringValue bio = 5;
google.protobuf.StringValue gender = 6;
google.protobuf.StringValue pronouns = 7;
google.protobuf.StringValue time_zone = 8;
google.protobuf.StringValue location = 9;
google.protobuf.Timestamp birthday = 10;
google.protobuf.Timestamp last_seen_at = 11;
VerificationMark verification = 12;
BadgeReferenceObject active_badge = 13;
VerificationMark verification = 12;
BadgeReferenceObject active_badge = 13;
int32 experience = 14;
int32 level = 15;
double leveling_progress = 16;
double social_credits = 17;
int32 social_credits_level = 18;
int32 experience = 14;
int32 level = 15;
double leveling_progress = 16;
double social_credits = 17;
int32 social_credits_level = 18;
CloudFile picture = 19;
CloudFile background = 20;
CloudFile picture = 19;
CloudFile background = 20;
string account_id = 21;
string account_id = 21;
google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23;
google.protobuf.Timestamp created_at = 22;
google.protobuf.Timestamp updated_at = 23;
optional UsernameColor username_color = 24;
}
// AccountContact represents a contact method for an account
message AccountContact {
string id = 1;
AccountContactType type = 2;
google.protobuf.Timestamp verified_at = 3;
bool is_primary = 4;
string content = 5;
string account_id = 6;
string id = 1;
AccountContactType type = 2;
google.protobuf.Timestamp verified_at = 3;
bool is_primary = 4;
string content = 5;
string account_id = 6;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
google.protobuf.Timestamp created_at = 7;
google.protobuf.Timestamp updated_at = 8;
}
// Enum for contact types
enum AccountContactType {
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
EMAIL = 1;
PHONE_NUMBER = 2;
ADDRESS = 3;
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
EMAIL = 1;
PHONE_NUMBER = 2;
ADDRESS = 3;
}
// AccountAuthFactor represents an authentication factor for an account
message AccountAuthFactor {
string id = 1;
AccountAuthFactorType type = 2;
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
int32 trustworthy = 5;
google.protobuf.Timestamp enabled_at = 6;
google.protobuf.Timestamp expired_at = 7;
string account_id = 8;
map<string, google.protobuf.Value> created_response = 9; // For initial setup
string id = 1;
AccountAuthFactorType type = 2;
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
int32 trustworthy = 5;
google.protobuf.Timestamp enabled_at = 6;
google.protobuf.Timestamp expired_at = 7;
string account_id = 8;
map<string, google.protobuf.Value> created_response = 9; // For initial setup
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
}
// Enum for authentication factor types
enum AccountAuthFactorType {
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
PASSWORD = 1;
EMAIL_CODE = 2;
IN_APP_CODE = 3;
TIMED_CODE = 4;
PIN_CODE = 5;
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
PASSWORD = 1;
EMAIL_CODE = 2;
IN_APP_CODE = 3;
TIMED_CODE = 4;
PIN_CODE = 5;
}
// AccountBadge represents a badge associated with an account
message AccountBadge {
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
}
// AccountConnection represents a third-party connection for an account
message AccountConnection {
string id = 1;
string provider = 2;
string provided_identifier = 3;
map<string, google.protobuf.Value> meta = 4;
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
google.protobuf.Timestamp last_used_at = 7;
string account_id = 8;
string id = 1;
string provider = 2;
string provided_identifier = 3;
map<string, google.protobuf.Value> meta = 4;
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
google.protobuf.Timestamp last_used_at = 7;
string account_id = 8;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
}
// VerificationMark represents verification status
message VerificationMark {
VerificationMarkType type = 1;
string title = 2;
string description = 3;
string verified_by = 4;
VerificationMarkType type = 1;
string title = 2;
string description = 3;
string verified_by = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
}
enum VerificationMarkType {
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
OFFICIAL = 1;
INDIVIDUAL = 2;
ORGANIZATION = 3;
GOVERNMENT = 4;
CREATOR = 5;
DEVELOPER = 6;
PARODY = 7;
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
OFFICIAL = 1;
INDIVIDUAL = 2;
ORGANIZATION = 3;
GOVERNMENT = 4;
CREATOR = 5;
DEVELOPER = 6;
PARODY = 7;
}
// BadgeReferenceObject represents a reference to a badge with minimal information
message BadgeReferenceObject {
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
}
// Relationship represents a connection between two accounts
message Relationship {
string account_id = 1;
string related_id = 2;
optional Account account = 3;
optional Account related = 4;
int32 status = 5;
string account_id = 1;
string related_id = 2;
optional Account account = 3;
optional Account related = 4;
int32 status = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
// Leveling information
message LevelingInfo {
int32 current_level = 1;
int32 current_experience = 2;
int32 next_level_experience = 3;
int32 previous_level_experience = 4;
double level_progress = 5;
repeated int32 experience_per_level = 6;
int32 current_level = 1;
int32 current_experience = 2;
int32 next_level_experience = 3;
int32 previous_level_experience = 4;
double level_progress = 5;
repeated int32 experience_per_level = 6;
}
// ActionLog represents a record of an action taken by a user
message ActionLog {
string id = 1; // Unique identifier for the log entry
string action = 2; // The action that was performed, e.g., "user.login"
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
google.protobuf.StringValue user_agent = 4; // User agent of the client
google.protobuf.StringValue ip_address = 5; // IP address of the client
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
string account_id = 7; // The account that performed the action
google.protobuf.StringValue session_id = 8; // The session in which the action was performed
string id = 1; // Unique identifier for the log entry
string action = 2; // The action that was performed, e.g., "user.login"
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
google.protobuf.StringValue user_agent = 4; // User agent of the client
google.protobuf.StringValue ip_address = 5; // IP address of the client
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
string account_id = 7; // The account that performed the action
google.protobuf.StringValue session_id = 8; // The session in which the action was performed
google.protobuf.Timestamp created_at = 9; // When the action log was created
google.protobuf.Timestamp created_at = 9; // When the action log was created
}
message GetAccountStatusBatchResponse {
repeated AccountStatus statuses = 1;
repeated AccountStatus statuses = 1;
}
// ====================================
@@ -248,45 +256,46 @@ message GetAccountStatusBatchResponse {
// AccountService provides CRUD operations for user accounts and related entities
service AccountService {
// Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
// Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc SearchAccount(SearchAccountRequest) returns (GetAccountBatchResponse) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
// Profile Operations
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
// Profile Operations
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
// Contact Operations
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
// Contact Operations
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
// Badge Operations
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
// Badge Operations
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
// Authentication Factor Operations
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
// Authentication Factor Operations
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
// Connection Operations
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
// Connection Operations
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
// Relationship Operations
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
// Relationship Operations
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
}
// ActionLogService provides operations for action logs
service ActionLogService {
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
}
// ====================================
@@ -295,184 +304,188 @@ service ActionLogService {
// ActionLog Requests/Responses
message CreateActionLogRequest {
string action = 1;
map<string, google.protobuf.Value> meta = 2;
google.protobuf.StringValue user_agent = 3;
google.protobuf.StringValue ip_address = 4;
google.protobuf.StringValue location = 5;
string account_id = 6;
google.protobuf.StringValue session_id = 7;
string action = 1;
map<string, google.protobuf.Value> meta = 2;
google.protobuf.StringValue user_agent = 3;
google.protobuf.StringValue ip_address = 4;
google.protobuf.StringValue location = 5;
string account_id = 6;
google.protobuf.StringValue session_id = 7;
}
message CreateActionLogResponse {
ActionLog action_log = 1;
ActionLog action_log = 1;
}
message ListActionLogsRequest {
string account_id = 1;
string action = 2;
int32 page_size = 3;
string page_token = 4;
string order_by = 5;
string account_id = 1;
string action = 2;
int32 page_size = 3;
string page_token = 4;
string order_by = 5;
}
message ListActionLogsResponse {
repeated ActionLog action_logs = 1;
string next_page_token = 2;
int32 total_size = 3;
repeated ActionLog action_logs = 1;
string next_page_token = 2;
int32 total_size = 3;
}
// Account Requests/Responses
message GetAccountRequest {
string id = 1; // Account ID to retrieve
string id = 1; // Account ID to retrieve
}
message GetBotAccountRequest {
string automated_id = 1;
string automated_id = 1;
}
message GetAccountBatchRequest {
repeated string id = 1; // Account ID to retrieve
repeated string id = 1; // Account ID to retrieve
}
message GetBotAccountBatchRequest {
repeated string automated_id = 1;
repeated string automated_id = 1;
}
message LookupAccountBatchRequest {
repeated string names = 1;
repeated string names = 1;
}
message SearchAccountRequest {
string query = 1;
}
message GetAccountBatchResponse {
repeated Account accounts = 1; // List of accounts
repeated Account accounts = 1; // List of accounts
}
message CreateAccountRequest {
string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name
string language = 3; // Default language
bool is_superuser = 4; // Admin flag
AccountProfile profile = 5; // Initial profile data
string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name
string language = 3; // Default language
bool is_superuser = 4; // Admin flag
AccountProfile profile = 5; // Initial profile data
}
message UpdateAccountRequest {
string id = 1; // Account ID to update
google.protobuf.StringValue name = 2; // New username if changing
google.protobuf.StringValue nick = 3; // New display name
google.protobuf.StringValue language = 4; // New language
google.protobuf.BoolValue is_superuser = 5; // Admin status
string id = 1; // Account ID to update
google.protobuf.StringValue name = 2; // New username if changing
google.protobuf.StringValue nick = 3; // New display name
google.protobuf.StringValue language = 4; // New language
google.protobuf.BoolValue is_superuser = 5; // Admin status
}
message DeleteAccountRequest {
string id = 1; // Account ID to delete
bool purge = 2; // If true, permanently delete instead of soft delete
string id = 1; // Account ID to delete
bool purge = 2; // If true, permanently delete instead of soft delete
}
message ListAccountsRequest {
int32 page_size = 1; // Number of results per page
string page_token = 2; // Token for pagination
string filter = 3; // Filter expression
string order_by = 4; // Sort order
int32 page_size = 1; // Number of results per page
string page_token = 2; // Token for pagination
string filter = 3; // Filter expression
string order_by = 4; // Sort order
}
message ListAccountsResponse {
repeated Account accounts = 1; // List of accounts
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of accounts
repeated Account accounts = 1; // List of accounts
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of accounts
}
// Profile Requests/Responses
message GetProfileRequest {
string account_id = 1; // Account ID to get profile for
string account_id = 1; // Account ID to get profile for
}
message UpdateProfileRequest {
string account_id = 1; // Account ID to update profile for
AccountProfile profile = 2; // Profile data to update
google.protobuf.FieldMask update_mask = 3; // Fields to update
string account_id = 1; // Account ID to update profile for
AccountProfile profile = 2; // Profile data to update
google.protobuf.FieldMask update_mask = 3; // Fields to update
}
// Contact Requests/Responses
message AddContactRequest {
string account_id = 1; // Account to add contact to
AccountContactType type = 2; // Type of contact
string content = 3; // Contact content (email, phone, etc.)
bool is_primary = 4; // If this should be the primary contact
string account_id = 1; // Account to add contact to
AccountContactType type = 2; // Type of contact
string content = 3; // Contact content (email, phone, etc.)
bool is_primary = 4; // If this should be the primary contact
}
message ListContactsRequest {
string account_id = 1; // Account ID to list contacts for
AccountContactType type = 2; // Optional: filter by type
bool verified_only = 3; // Only return verified contacts
string account_id = 1; // Account ID to list contacts for
AccountContactType type = 2; // Optional: filter by type
bool verified_only = 3; // Only return verified contacts
}
message ListContactsResponse {
repeated AccountContact contacts = 1; // List of contacts
repeated AccountContact contacts = 1; // List of contacts
}
message VerifyContactRequest {
string id = 1; // Contact ID to verify
string account_id = 2; // Account ID (for validation)
string code = 3; // Verification code
string id = 1; // Contact ID to verify
string account_id = 2; // Account ID (for validation)
string code = 3; // Verification code
}
// Badge Requests/Responses
message ListBadgesRequest {
string account_id = 1; // Account to list badges for
string type = 2; // Optional: filter by type
bool active_only = 3; // Only return active (non-expired) badges
string account_id = 1; // Account to list badges for
string type = 2; // Optional: filter by type
bool active_only = 3; // Only return active (non-expired) badges
}
message ListBadgesResponse {
repeated AccountBadge badges = 1; // List of badges
repeated AccountBadge badges = 1; // List of badges
}
message ListAuthFactorsRequest {
string account_id = 1; // Account to list factors for
bool active_only = 2; // Only return active (non-expired) factors
string account_id = 1; // Account to list factors for
bool active_only = 2; // Only return active (non-expired) factors
}
message ListAuthFactorsResponse {
repeated AccountAuthFactor factors = 1; // List of auth factors
repeated AccountAuthFactor factors = 1; // List of auth factors
}
message ListConnectionsRequest {
string account_id = 1; // Account to list connections for
string provider = 2; // Optional: filter by provider
string account_id = 1; // Account to list connections for
string provider = 2; // Optional: filter by provider
}
message ListConnectionsResponse {
repeated AccountConnection connections = 1; // List of connections
repeated AccountConnection connections = 1; // List of connections
}
// Relationship Requests/Responses
message ListRelationshipsRequest {
string account_id = 1; // Account to list relationships for
optional int32 status = 2; // Filter by status
int32 page_size = 5; // Number of results per page
string page_token = 6; // Token for pagination
string account_id = 1; // Account to list relationships for
optional int32 status = 2; // Filter by status
int32 page_size = 5; // Number of results per page
string page_token = 6; // Token for pagination
}
message ListRelationshipsResponse {
repeated Relationship relationships = 1; // List of relationships
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of relationships
repeated Relationship relationships = 1; // List of relationships
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of relationships
}
message GetRelationshipRequest {
string account_id = 1;
string related_id = 2;
optional int32 status = 3;
string account_id = 1;
string related_id = 2;
optional int32 status = 3;
}
message GetRelationshipResponse {
optional Relationship relationship = 1;
optional Relationship relationship = 1;
}
message ListRelationshipSimpleRequest {
string account_id = 1;
string account_id = 1;
}
message ListRelationshipSimpleResponse {
repeated string accounts_id = 1;
repeated string accounts_id = 1;
}

View File

@@ -11,7 +11,7 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
var response = await accounts.GetAccountAsync(request);
return response;
}
public async Task<Account> GetBotAccount(Guid automatedId)
{
var request = new GetBotAccountRequest { AutomatedId = automatedId.ToString() };
@@ -26,7 +26,14 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
var response = await accounts.GetAccountBatchAsync(request);
return response.Accounts.ToList();
}
public async Task<List<Account>> SearchAccounts(string query)
{
var request = new SearchAccountRequest { Query = query };
var response = await accounts.SearchAccountAsync(request);
return response.Accounts.ToList();
}
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
{
var request = new GetBotAccountBatchRequest();

View File

@@ -0,0 +1,20 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Autocompletion;
[ApiController]
[Route("/api/autocomplete")]
public class AutocompletionController(AutocompletionService aus) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> TextAutocomplete([FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}

View File

@@ -1,10 +1,12 @@
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Registry;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Autocompletion;
public class AutocompletionService(AppDatabase db)
public class AutocompletionService(AppDatabase db, AccountClientHelper accountsHelper)
{
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, int limit = 10)
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10)
{
if (string.IsNullOrWhiteSpace(content))
return [];
@@ -14,7 +16,8 @@ public class AutocompletionService(AppDatabase db)
var afterAt = content[1..];
string type;
string query;
if (afterAt.Contains('/'))
var hadSlash = afterAt.Contains('/');
if (hadSlash)
{
var parts = afterAt.Split('/', 2);
type = parts[0];
@@ -25,7 +28,8 @@ public class AutocompletionService(AppDatabase db)
type = "u";
query = afterAt;
}
return await AutocompleteAt(type, query, limit);
return await AutocompleteAt(type, query, chatId, realmId, hadSlash, limit);
}
if (!content.StartsWith(':')) return [];
@@ -33,15 +37,49 @@ public class AutocompletionService(AppDatabase db)
var query = content[1..];
return await AutocompleteSticker(query, limit);
}
}
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, int limit)
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, Guid? chatId, Guid? realmId, bool hadSlash,
int limit)
{
var results = new List<DysonNetwork.Shared.Models.Autocompletion>();
switch (type)
{
case "u":
var allAccounts = await accountsHelper.SearchAccounts(query);
var filteredAccounts = allAccounts;
if (chatId.HasValue)
{
var chatMemberIds = await db.ChatMembers
.Where(m => m.ChatRoomId == chatId.Value && m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var chatMemberIdStrings = chatMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => chatMemberIdStrings.Contains(a.Id)).ToList();
}
else if (realmId.HasValue)
{
var realmMemberIds = await db.RealmMembers
.Where(m => m.RealmId == realmId.Value && m.LeaveAt == null)
.Select(m => m.AccountId)
.ToListAsync();
var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet();
filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList();
}
var users = filteredAccounts
.Take(limit)
.Select(a => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "user",
Keyword = "@" + (hadSlash ? "u/" : "") + a.Name,
Data = SnAccount.FromProtoValue(a)
})
.ToList();
results.AddRange(users);
break;
case "p":
var publishers = await db.Publishers
.Where(p => EF.Functions.Like(p.Name, $"{query}%") || EF.Functions.Like(p.Nick, $"{query}%"))
@@ -49,7 +87,7 @@ public class AutocompletionService(AppDatabase db)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "publisher",
Keyword = p.Name,
Keyword = "@p/" + p.Name,
Data = p
})
.ToListAsync();
@@ -63,7 +101,7 @@ public class AutocompletionService(AppDatabase db)
.Select(r => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "realm",
Keyword = r.Slug,
Keyword = "@r/" + r.Slug,
Data = r
})
.ToListAsync();
@@ -77,7 +115,7 @@ public class AutocompletionService(AppDatabase db)
.Select(c => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "chat",
Keyword = c.Name!,
Keyword = "@c/" + c.Name,
Data = c
})
.ToListAsync();
@@ -92,30 +130,17 @@ public class AutocompletionService(AppDatabase db)
{
var stickers = await db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Slug, $"{query}%"))
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.Take(limit)
.Select(s => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "sticker",
Keyword = s.Slug,
Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
Data = s
})
.ToListAsync();
// Also possibly search by pack prefix? But user said slug after :
// Perhaps combine or search packs
var packs = await db.StickerPacks
.Where(p => EF.Functions.Like(p.Prefix, $"{query}%"))
.Take(limit)
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
{
Type = "sticker_pack",
Keyword = p.Prefix,
Data = p
})
.ToListAsync();
var results = stickers.Concat(packs).Take(limit).ToList();
var results = stickers.ToList();
return results;
}
}

View File

@@ -87,7 +87,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room.");
@@ -103,10 +104,10 @@ public partial class ChatController(
.Skip(offset)
.Take(take)
.ToListAsync();
var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList();
members = await crs.LoadMemberAccounts(members);
foreach (var message in messages)
message.Sender = members.First(x => x.Id == message.SenderId);
@@ -129,7 +130,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id);
var member = await db.ChatMembers
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
m.LeaveAt == null)
.FirstOrDefaultAsync();
if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You are not a member of this chat room.");
@@ -141,16 +143,81 @@ public partial class ChatController(
.FirstOrDefaultAsync();
if (message is null) return NotFound();
message.Sender = await crs.LoadMemberAccount(message.Sender);
return Ok(message);
}
[GeneratedRegex("@([A-Za-z0-9_-]+)")]
[GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
private static partial Regex MentionRegex();
/// <summary>
/// Extracts mentioned users from message content, replies, and forwards
/// </summary>
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
{
var mentionedUsers = new List<Guid>();
// Add sender of a replied message
if (repliedMessageId.HasValue)
{
var replyingTo = await db.ChatMessages
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
.Include(m => m.Sender)
.Select(m => m.Sender)
.FirstOrDefaultAsync();
if (replyingTo != null)
mentionedUsers.Add(replyingTo.AccountId);
}
// Add sender of a forwarded message
if (forwardedMessageId.HasValue)
{
var forwardedMessage = await db.ChatMessages
.Where(m => m.Id == forwardedMessageId.Value)
.Select(m => new { m.SenderId })
.FirstOrDefaultAsync();
if (forwardedMessage != null)
{
mentionedUsers.Add(forwardedMessage.SenderId);
}
}
// Extract mentions from content using regex
if (!string.IsNullOrWhiteSpace(content))
{
var mentionedNames = MentionRegex()
.Matches(content)
.Select(m => m.Groups[1].Value)
.Distinct()
.ToList();
if (mentionedNames.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentionedNames);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
if (mentionedIds.Count > 0)
{
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
.Select(m => m.AccountId)
.ToListAsync();
mentionedUsers.AddRange(mentionedMembers);
}
}
}
return mentionedUsers.Distinct().ToList();
}
[HttpPost("{roomId:guid}/messages")]
[Authorize]
[RequiredPermission("global", "chat.messages.create")]
@@ -188,6 +255,7 @@ public partial class ChatController(
.ToList();
}
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue)
{
var repliedMessage = await db.ChatMessages
@@ -208,28 +276,9 @@ public partial class ChatController(
message.ForwardedMessageId = forwardedMessage.Id;
}
if (request.Content is not null)
{
var mentioned = MentionRegex()
.Matches(request.Content)
.Select(m => m.Groups[1].Value)
.ToList();
if (mentioned.Count > 0)
{
var queryRequest = new LookupAccountBatchRequest();
queryRequest.Names.AddRange(mentioned);
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
var mentionedId = queryResponse
.Select(a => Guid.Parse(a.Id))
.ToList();
var mentionedMembers = await db.ChatMembers
.Where(m => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
.Select(m => m.Id)
.ToListAsync();
message.MembersMentioned = mentionedMembers;
}
}
// Extract mentioned users
message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId);
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
@@ -259,6 +308,7 @@ public partial class ChatController(
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
return BadRequest("You cannot send an empty message.");
// Validate reply and forward message IDs exist
if (request.RepliedMessageId.HasValue)
{
var repliedMessage = await db.ChatMessages
@@ -275,6 +325,11 @@ public partial class ChatController(
return BadRequest("The message you're forwarding does not exist.");
}
// Update mentions based on new content and references
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
request.ForwardedMessageId, roomId, accountId);
message.MembersMentioned = updatedMentions;
// Call service method to update the message
await cs.UpdateMessageAsync(
message,
@@ -324,7 +379,8 @@ public partial class ChatController(
var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
.AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
@@ -332,19 +388,21 @@ public partial class ChatController(
return Ok(response);
}
[HttpGet("{roomId:guid}/autocomplete")]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete([FromBody] AutocompletionRequest request, Guid roomId)
[HttpPost("{roomId:guid}/autocomplete")]
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
[FromBody] AutocompletionRequest request, Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var isMember = await db.ChatMembers
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
.AnyAsync(m =>
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
if (!isMember)
return StatusCode(403, "You are not a member of this chat room.");
var result = await aus.GetAutocompletion(request.Content, limit: 10);
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
return Ok(result);
}
}
}

View File

@@ -198,8 +198,6 @@ public partial class ChatService(
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
{
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
message.UpdatedAt = message.CreatedAt;
// First complete the save operation
db.ChatMessages.Add(message);
@@ -209,20 +207,25 @@ public partial class ChatService(
await CreateFileReferencesForMessageAsync(message);
// Then start the delivery process
var localMessage = message;
var localSender = sender;
var localRoom = room;
var localLogger = logger;
_ = Task.Run(async () =>
{
try
{
await DeliverMessageAsync(message, sender, room);
await DeliverMessageAsync(localMessage, localSender, localRoom);
}
catch (Exception ex)
{
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
}
});
// Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
var localMessageForPreview = message;
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
message.Sender = sender;
message.ChatRoom = room;

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.WebReader;
@@ -23,6 +24,7 @@ public class PostController(
AppDatabase db,
PostService ps,
PublisherService pub,
AccountClientHelper accountsHelper,
AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
@@ -97,7 +99,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -197,7 +199,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -228,7 +230,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -271,6 +273,14 @@ public class PostController(
.Take(take)
.Skip(offset)
.ToListAsync();
var accountsProto = await accountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
foreach (var reaction in reactions)
if (accounts.TryGetValue(reaction.AccountId, out var account))
reaction.Account = account;
return Ok(reactions);
}
@@ -283,7 +293,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -314,7 +324,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -342,7 +352,7 @@ public class PostController(
if (currentUser != null)
{
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id });
{ AccountId = currentUser.Id });
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
}
@@ -448,7 +458,10 @@ public class PostController(
if (request.RepliedPostId is not null)
{
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value);
var repliedPost = await db.Posts
.Where(p => p.Id == request.RepliedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (repliedPost is null) return BadRequest("Post replying to was not found.");
post.RepliedPost = repliedPost;
post.RepliedPostId = repliedPost.Id;
@@ -456,7 +469,10 @@ public class PostController(
if (request.ForwardedPostId is not null)
{
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value);
var forwardedPost = await db.Posts
.Where(p => p.Id == request.ForwardedPostId.Value)
.Include(p => p.Publisher)
.FirstOrDefaultAsync();
if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
post.ForwardedPost = forwardedPost;
post.ForwardedPostId = forwardedPost.Id;
@@ -514,7 +530,7 @@ public class PostController(
});
post.Publisher = publisher;
return post;
}
@@ -536,7 +552,7 @@ public class PostController(
var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() });
{ AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
@@ -632,7 +648,7 @@ public class PostController(
var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() });
{ AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
@@ -883,7 +899,7 @@ public class PostController(
UserAgent = Request.Headers.UserAgent,
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
});
return Ok(post);
}
@@ -915,4 +931,4 @@ public class PostController(
return NoContent();
}
}
}

View File

@@ -14,7 +14,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
// Add application services
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppServices();
builder.Services.AddAppRateLimiting();
builder.Services.AddAppAuthentication();
builder.Services.AddDysonAuth();

View File

@@ -282,9 +282,9 @@ public class PublisherService(
public int SubscribersCount { get; set; }
}
private const string PublisherStatsCacheKey = "PublisherStats_{0}";
private const string PublisherHeatmapCacheKey = "PublisherHeatmap_{0}";
private const string PublisherFeatureCacheKey = "PublisherFeature_{0}_{1}";
private const string PublisherStatsCacheKey = "publisher:{0}:stats";
private const string PublisherHeatmapCacheKey = "publisher:{0}:heatmap";
private const string PublisherFeatureCacheKey = "publisher:{0}:feature:{1}";
public async Task<PublisherStats?> GetPublisherStats(string name)
{
@@ -329,7 +329,7 @@ public class PublisherService(
public async Task<ActivityHeatmap?> GetPublisherHeatmap(string name)
{
var cacheKey = string.Format(PublisherHeatmapCacheKey, name);
var heatmap = await cache.GetAsync<ActivityHeatmap>(cacheKey);
var heatmap = await cache.GetAsync<ActivityHeatmap?>(cacheKey);
if (heatmap is not null)
return heatmap;

View File

@@ -15,6 +15,7 @@ using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Autocompletion;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Discovery;
@@ -25,7 +26,7 @@ namespace DysonNetwork.Sphere.Startup;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddAppServices(this IServiceCollection services)
{
services.AddLocalization(options => options.ResourcesPath = "Resources");
@@ -40,7 +41,6 @@ public static class ServiceCollectionExtensions
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options =>
@@ -119,6 +119,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<PollService>();
services.AddScoped<AccountClientHelper>();
services.AddScoped<AutocompletionService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower();

View File

@@ -237,6 +237,22 @@ public class StickerController(
return Redirect($"/drive/files/{sticker.Image.Id}?original=true");
}
[HttpGet("search")]
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
{
var queryable = db.Stickers
.Include(s => s.Pack)
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
.OrderByDescending(s => s.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var stickers = await queryable.Take(take).Skip(offset).ToListAsync();
return Ok(stickers);
}
[HttpGet("{packId:guid}/content/{id:guid}")]
public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id)
{
@@ -420,4 +436,4 @@ public class StickerController(
return NoContent();
}
}
}