From a37f468b83a9671b426477a2dcfd17c85459f864 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 14 Feb 2026 18:24:58 +0100 Subject: [PATCH 1/5] Implement hearting user over API (with pin support) --- .../Endpoints/UserApiEndpoints.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index fd930258..f1386dbc 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -9,6 +9,8 @@ using Refresh.Core.Services; using Refresh.Core.Types.Data; using Refresh.Database; +using Refresh.Database.Models.Authentication; +using Refresh.Database.Models.Pins; using Refresh.Database.Models.Users; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; @@ -45,6 +47,35 @@ public ApiResponse GetUserByUuid(RequestContext context, Ga return ApiGameUserResponse.FromOld(user, dataContext); } + + // TODO: Also allow specifying user by username + [ApiV3Endpoint("users/uuid/{uuid}/heart", HttpMethods.Post)] + [DocSummary("Hearts a user by their UUID")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] + public ApiResponse HeartUserByUuid(RequestContext context, GameDatabaseContext database, + [DocSummary("The UUID of the user")] string uuid, DataContext dataContext, GameUser user) + { + GameUser? target = database.GetUserByUuid(uuid); + if(target == null) return ApiNotFoundError.UserMissingError; + + database.FavouriteUser(target, user); + database.IncrementUserPinProgress((long)ServerPins.HeartPlayerOnWebsite, 1, user, false, TokenPlatform.Website); + + return new ApiOkResponse(); + } + + [ApiV3Endpoint("users/uuid/{uuid}/unheart", HttpMethods.Post)] + [DocSummary("Unhearts a user by their UUID")] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] + public ApiResponse UnheartUserByUuid(RequestContext context, GameDatabaseContext database, + [DocSummary("The UUID of the user")] string uuid, DataContext dataContext, GameUser user) + { + GameUser? target = database.GetUserByUuid(uuid); + if(target == null) return ApiNotFoundError.UserMissingError; + + database.UnfavouriteUser(target, user); + return new ApiOkResponse(); + } [ApiV3Endpoint("users/me"), MinimumRole(GameUserRole.Restricted)] [DocSummary("Returns your own user, provided you are authenticated")] From 3cf1890d5cb6b694f68bdd08bcdd40c43765d933 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Sat, 14 Feb 2026 18:27:57 +0100 Subject: [PATCH 2/5] Include whether the user has hearted a user in their response --- .../Users/ApiGameUserOwnRelationsResponse.cs | 21 +++++++++++++++++++ .../Response/Users/ApiGameUserResponse.cs | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs new file mode 100644 index 00000000..5014ab1f --- /dev/null +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserOwnRelationsResponse.cs @@ -0,0 +1,21 @@ +using Refresh.Core.Types.Data; +using Refresh.Database.Models.Users; + +namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users; + +[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))] +public class ApiGameUserOwnRelationsResponse : IApiResponse +{ + public required bool IsHearted { get; set; } + + public static ApiGameUserOwnRelationsResponse? FromOld(GameUser user, DataContext dataContext) + { + if (dataContext.User == null) + return null; + + return new() + { + IsHearted = dataContext.Database.IsUserFavouritedByUser(user, dataContext.User), + }; + } +} \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs index 91c2f32a..f0f0a61d 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiGameUserResponse.cs @@ -26,6 +26,7 @@ public class ApiGameUserResponse : IApiResponse, IDataConvertableFrom null; notnull => notnull")] @@ -51,6 +52,7 @@ public class ApiGameUserResponse : IApiResponse, IDataConvertableFrom Date: Sat, 14 Feb 2026 18:59:46 +0100 Subject: [PATCH 3/5] Add OwnRelations to ApiExtendedGameUserResponse --- .../DataTypes/Response/Users/ApiExtendedGameUserResponse.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs index dbad276a..d357d51e 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Users/ApiExtendedGameUserResponse.cs @@ -69,6 +69,7 @@ public class ApiExtendedGameUserResponse : ApiGameUserResponse, IApiResponse, ID UnescapeXmlSequences = user.UnescapeXmlSequences, FilesizeQuotaUsage = user.FilesizeQuotaUsage, Statistics = ApiGameUserStatisticsResponse.FromOld(user, dataContext)!, + OwnRelations = ApiGameUserOwnRelationsResponse.FromOld(user, dataContext), ActiveRoom = ApiGameRoomResponse.FromOld(dataContext.Match.RoomAccessor.GetRoomByUser(user), dataContext), LevelVisibility = user.LevelVisibility, ProfileVisibility = user.ProfileVisibility, From 3daf9619eec28a5be529b850ea26ef048c0be5da Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 16 Feb 2026 12:34:46 +0100 Subject: [PATCH 4/5] Test user own relations inclusion --- .../Tests/ApiV3/UserApiTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs index c3f48e03..154e8118 100644 --- a/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs +++ b/RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs @@ -343,4 +343,23 @@ public void GetsNewestUsers(bool showOnlineUsers) index++; } } + + [Test] + public void OnlyIncludesOwnUserRelationsWhenSignedIn() + { + using TestContext context = this.GetServer(); + GameUser otherUser = context.CreateUser(); + GameUser me = context.CreateUser(); + + // Try fetching the user without being signed in + ApiResponse? response = context.Http.GetData($"/api/v3/users/name/{otherUser.Username}"); + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response!.Data!.OwnRelations, Is.Null); + + // Sign in and then get user again + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, me); + response = client.GetData($"/api/v3/users/name/{otherUser.Username}"); + Assert.That(response?.Data, Is.Not.Null); + Assert.That(response!.Data!.OwnRelations, Is.Not.Null); + } } \ No newline at end of file From 978d3a65239e1d3375d147bab8ebf1f0448735e9 Mon Sep 17 00:00:00 2001 From: Toaster2 Date: Mon, 16 Feb 2026 12:52:42 +0100 Subject: [PATCH 5/5] Fix user hearting return type, slightly more accurate pin objective implementation --- .../Endpoints/LevelApiEndpoints.cs | 8 +++++--- .../Endpoints/UserApiEndpoints.cs | 12 ++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs index ec56a68a..9d2157e0 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/LevelApiEndpoints.cs @@ -181,10 +181,12 @@ public ApiOkResponse QueueLevel(RequestContext context, GameDatabaseContext data GameLevel? level = database.GetLevelById(id); if (level == null) return ApiNotFoundError.LevelMissingError; - database.QueueLevel(level, user); + bool success = database.QueueLevel(level, user); - // Update pin progress for queueing a level through the API - database.IncrementUserPinProgress((long)ServerPins.QueueLevelOnWebsite, 1, user, false, TokenPlatform.Website); + // Only give pin if the level was queued without having already been queued. + // Won't protect against spam, but this way the pin objective is more accurately implemented. + if (success) + database.IncrementUserPinProgress((long)ServerPins.QueueLevelOnWebsite, 1, user, false, TokenPlatform.Website); return new ApiOkResponse(); } diff --git a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs index f1386dbc..ad4dedfc 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/UserApiEndpoints.cs @@ -52,14 +52,18 @@ public ApiResponse GetUserByUuid(RequestContext context, Ga [ApiV3Endpoint("users/uuid/{uuid}/heart", HttpMethods.Post)] [DocSummary("Hearts a user by their UUID")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] - public ApiResponse HeartUserByUuid(RequestContext context, GameDatabaseContext database, + public ApiOkResponse HeartUserByUuid(RequestContext context, GameDatabaseContext database, [DocSummary("The UUID of the user")] string uuid, DataContext dataContext, GameUser user) { GameUser? target = database.GetUserByUuid(uuid); if(target == null) return ApiNotFoundError.UserMissingError; - database.FavouriteUser(target, user); - database.IncrementUserPinProgress((long)ServerPins.HeartPlayerOnWebsite, 1, user, false, TokenPlatform.Website); + bool success = database.FavouriteUser(target, user); + + // Only give pin if the user was hearted without having already been hearted. + // Won't protect against spam, but this way the pin objective is more accurately implemented. + if (success) + database.IncrementUserPinProgress((long)ServerPins.HeartPlayerOnWebsite, 1, user, false, TokenPlatform.Website); return new ApiOkResponse(); } @@ -67,7 +71,7 @@ public ApiResponse HeartUserByUuid(RequestContext context, GameDa [ApiV3Endpoint("users/uuid/{uuid}/unheart", HttpMethods.Post)] [DocSummary("Unhearts a user by their UUID")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] - public ApiResponse UnheartUserByUuid(RequestContext context, GameDatabaseContext database, + public ApiOkResponse UnheartUserByUuid(RequestContext context, GameDatabaseContext database, [DocSummary("The UUID of the user")] string uuid, DataContext dataContext, GameUser user) { GameUser? target = database.GetUserByUuid(uuid);