From e11fae8e3177ba391c34ec7d592950c66d1b6b88 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 21 Oct 2026 14:54:48 +0200 Subject: [PATCH] feat(follow): add player search with rate limit (#57) Find public profiles by name: GET /api/follows/players?q=. Queries under 2 characters find nothing; more than 30 searches per minute get 429. LIKE escaping is shared with the library search. --- readme.md | 8 ++- .../modules/follow/api/FollowController.java | 18 +++++ .../modules/follow/api/dto/PlayersDto.java | 15 ++++ .../modules/follow/domain/PlayerSearch.java | 17 +++++ .../modules/follow/repo/FollowRepository.java | 19 ++++++ .../modules/follow/service/FollowService.java | 20 ++++++ .../follow/service/PlayerSearchLimiter.java | 32 +++++++++ .../library/repo/UserGameRepository.java | 6 +- .../core_service/shared/sql/LikePattern.java | 16 +++++ src/main/resources/application.yml | 2 + .../follow/api/FollowControllerTest.java | 68 ++++++++++++++++++- .../service/PlayerSearchLimiterTest.java | 19 ++++++ 12 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 src/main/java/core_service/modules/follow/api/dto/PlayersDto.java create mode 100644 src/main/java/core_service/modules/follow/domain/PlayerSearch.java create mode 100644 src/main/java/core_service/modules/follow/service/PlayerSearchLimiter.java create mode 100644 src/main/java/core_service/shared/sql/LikePattern.java create mode 100644 src/test/java/core_service/modules/follow/service/PlayerSearchLimiterTest.java diff --git a/readme.md b/readme.md index 6a80fce..a508d2b 100644 --- a/readme.md +++ b/readme.md @@ -59,9 +59,10 @@ To Be Played is split into four repositories: Filters --> Out["games + reason"] ``` - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most - 500, never an id in an answer) and gets one inbox note per followed user and batch (`/api/inbox`): a sync with its - achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status - changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). + 500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets + one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a + quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves + out what the followed user's profile hides (`public_user.sections`). Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for @@ -308,6 +309,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login | | `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login | | `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login | +| `GET` | `/api/follows/players?q=` (`{results: [{displayName}]}`: public profiles whose name contains `q`, case-insensitive, not blocked or deleted, at most 20, names that start with `q` first, the caller's own name included; `q` under 2 characters answers `[]`; `429 TOO_MANY_SEARCHES` beyond 30 searches per user and minute, `app.follow.search-per-minute`) | login | | `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login | | `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login | | `PUT` | `/api/inbox/read` (marks all read; `204`) | login | diff --git a/src/main/java/core_service/modules/follow/api/FollowController.java b/src/main/java/core_service/modules/follow/api/FollowController.java index 7e1a3c7..44a45ef 100644 --- a/src/main/java/core_service/modules/follow/api/FollowController.java +++ b/src/main/java/core_service/modules/follow/api/FollowController.java @@ -1,14 +1,17 @@ package core_service.modules.follow.api; import core_service.modules.follow.api.dto.FollowsDto; +import core_service.modules.follow.api.dto.PlayersDto; import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.Follows; +import core_service.modules.follow.domain.PlayerSearch; import core_service.modules.follow.service.FollowService; import core_service.shared.api.ErrorResponse; import core_service.shared.security.UserPrincipal; import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -33,6 +37,20 @@ public class FollowController { return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers()))); } + /** + * The caller's own name is a normal hit. + */ + @GetMapping("/players") + public ResponseEntity players(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam("q") @Size(max = 100) String q) { + PlayerSearch search = followService.searchPlayers(principal.getIdAsLong(), q); + if (search.limited()) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(new ErrorResponse("TOO_MANY_SEARCHES", "Too many searches")); + } + return ResponseEntity.ok(new PlayersDto(search.names().stream().map(PlayersDto.Player::new).toList())); + } + @PutMapping("/by-name/{displayName}") public ResponseEntity follow(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("displayName") @Size(max = 100) String displayName) { diff --git a/src/main/java/core_service/modules/follow/api/dto/PlayersDto.java b/src/main/java/core_service/modules/follow/api/dto/PlayersDto.java new file mode 100644 index 0000000..1a69713 --- /dev/null +++ b/src/main/java/core_service/modules/follow/api/dto/PlayersDto.java @@ -0,0 +1,15 @@ +package core_service.modules.follow.api.dto; + +import java.util.List; + +/** + * Public profiles found by name: names that start with the query first, then by name. + */ +public record PlayersDto(List results) { + + /** + * @param displayName the key of the profile URL + */ + public record Player(String displayName) { + } +} diff --git a/src/main/java/core_service/modules/follow/domain/PlayerSearch.java b/src/main/java/core_service/modules/follow/domain/PlayerSearch.java new file mode 100644 index 0000000..4eee929 --- /dev/null +++ b/src/main/java/core_service/modules/follow/domain/PlayerSearch.java @@ -0,0 +1,17 @@ +package core_service.modules.follow.domain; + +import java.util.List; + +/** + * The answer of a player search: the names found, or "limited" when the caller searched too often. + */ +public record PlayerSearch(List names, boolean limited) { + + public static PlayerSearch tooMany() { + return new PlayerSearch(List.of(), true); + } + + public static PlayerSearch of(List names) { + return new PlayerSearch(names, false); + } +} diff --git a/src/main/java/core_service/modules/follow/repo/FollowRepository.java b/src/main/java/core_service/modules/follow/repo/FollowRepository.java index 7fe5913..f078242 100644 --- a/src/main/java/core_service/modules/follow/repo/FollowRepository.java +++ b/src/main/java/core_service/modules/follow/repo/FollowRepository.java @@ -1,6 +1,7 @@ package core_service.modules.follow.repo; import core_service.modules.follow.domain.FollowEntry; +import core_service.shared.sql.LikePattern; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; @@ -20,6 +21,7 @@ import java.util.Optional; public class FollowRepository { static final int LIST_LIMIT = 500; + public static final int SEARCH_LIMIT = 20; private static final RowMapper ENTRY = (rs, i) -> { Timestamp lastActivityAt = rs.getTimestamp("last_activity_at"); @@ -45,6 +47,23 @@ public class FollowRepository { return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty(); } + /** + * Public names that contain the query, case-insensitive; names that start with it come first. Same visibility + * rules as {@link #findFollowable}. Wildcards in the query match themselves. + */ + public List searchPublicNames(String query, int limit) { + String escaped = LikePattern.escapeLower(query); + return jdbc.queryForList(""" + select a.display_name from account_name a + join public_user p on p.user_id = a.user_id + where lower(a.display_name) like :pattern escape '\\' + and not exists (select 1 from blocked_user b where b.user_id = a.user_id) + and not exists (select 1 from deleted_user d where d.user_id = a.user_id) + order by (lower(a.display_name) like :prefix escape '\\') desc, lower(a.display_name) + limit :limit""", new MapSqlParameterSource("pattern", "%" + escaped + "%") + .addValue("prefix", escaped + "%").addValue("limit", limit), String.class); + } + /** * Checks the visibility again in the same statement, so a user who went private in between gets no follower. * diff --git a/src/main/java/core_service/modules/follow/service/FollowService.java b/src/main/java/core_service/modules/follow/service/FollowService.java index 39284fc..1ecc156 100644 --- a/src/main/java/core_service/modules/follow/service/FollowService.java +++ b/src/main/java/core_service/modules/follow/service/FollowService.java @@ -2,6 +2,7 @@ package core_service.modules.follow.service; import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.Follows; +import core_service.modules.follow.domain.PlayerSearch; import core_service.modules.follow.repo.FollowRepository; import core_service.shared.security.RequiresRole; import core_service.shared.security.Role; @@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Service @@ -17,8 +19,10 @@ import java.util.Optional; public class FollowService { static final int MAX_FOLLOWING = 500; + static final int MIN_QUERY_LENGTH = 2; private final FollowRepository followRepository; + private final PlayerSearchLimiter limiter; /** * Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed. @@ -57,4 +61,20 @@ public class FollowService { public Follows follows(long userId) { return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId)); } + + /** + * A query shorter than two characters finds nothing and does not count against the limit. + */ + @RequiresRole(Role.USER) + @Transactional(readOnly = true) + public PlayerSearch searchPlayers(long userId, String query) { + String stripped = query.strip(); + if (stripped.length() < MIN_QUERY_LENGTH) { + return PlayerSearch.of(List.of()); + } + if (!limiter.allow(userId)) { + return PlayerSearch.tooMany(); + } + return PlayerSearch.of(followRepository.searchPublicNames(stripped, FollowRepository.SEARCH_LIMIT)); + } } diff --git a/src/main/java/core_service/modules/follow/service/PlayerSearchLimiter.java b/src/main/java/core_service/modules/follow/service/PlayerSearchLimiter.java new file mode 100644 index 0000000..d1fe25e --- /dev/null +++ b/src/main/java/core_service/modules/follow/service/PlayerSearchLimiter.java @@ -0,0 +1,32 @@ +package core_service.modules.follow.service; + +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RateLimiterConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Searches per user and minute. One limiter per user who searched since boot stays in memory: small, and bounded by + * the number of users. The limit holds per instance. + */ +@Component +public class PlayerSearchLimiter { + + private final RateLimiterConfig config; + private final ConcurrentHashMap limiters = new ConcurrentHashMap<>(); + + public PlayerSearchLimiter(@Value("${app.follow.search-per-minute:30}") int perMinute) { + this.config = RateLimiterConfig.custom() + .limitForPeriod(perMinute) + .limitRefreshPeriod(Duration.ofMinutes(1)) + .timeoutDuration(Duration.ZERO) + .build(); + } + + public boolean allow(long userId) { + return limiters.computeIfAbsent(userId, id -> RateLimiter.of("player-search-" + id, config)).acquirePermission(); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 47cecad..4b3a19c 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; +import core_service.shared.sql.LikePattern; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -28,7 +29,6 @@ import java.util.EnumMap; import java.util.EnumSet; import java.util.List; import java.util.Map; -import java.util.Locale; import java.util.Optional; public interface UserGameRepository extends JpaRepository { @@ -448,9 +448,7 @@ public interface UserGameRepository extends JpaRepository */ static String containsPattern(String query) { if (query == null || query.isBlank()) return null; - String escaped = query.trim().toLowerCase(Locale.ROOT) - .replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); - return "%" + escaped + "%"; + return "%" + LikePattern.escapeLower(query.trim()) + "%"; } /** diff --git a/src/main/java/core_service/shared/sql/LikePattern.java b/src/main/java/core_service/shared/sql/LikePattern.java new file mode 100644 index 0000000..5139e76 --- /dev/null +++ b/src/main/java/core_service/shared/sql/LikePattern.java @@ -0,0 +1,16 @@ +package core_service.shared.sql; + +import java.util.Locale; + +public final class LikePattern { + + private LikePattern() {} + + /** + * Lower-cases the text and escapes {@code \}, {@code %} and {@code _}, so a {@code like ... escape '\'} matches + * them literally. The caller adds the wildcards. + */ + public static String escapeLower(String text) { + return text.toLowerCase(Locale.ROOT).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ca7f3dd..404395f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -106,6 +106,8 @@ app: # The hand changes of a followed user are one batch after this quiet time. digest-ms: 600000 quiet-minutes: 10 + # Player searches per user and minute; more answers 429. + search-per-minute: 30 inbox: purge-cron: "0 15 3 * * *" keep-days: 90 diff --git a/src/test/java/core_service/modules/follow/api/FollowControllerTest.java b/src/test/java/core_service/modules/follow/api/FollowControllerTest.java index 8c5a66b..82a5829 100644 --- a/src/test/java/core_service/modules/follow/api/FollowControllerTest.java +++ b/src/test/java/core_service/modules/follow/api/FollowControllerTest.java @@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -83,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest { } private JsonNode list(long userId) throws Exception { - String body = mockMvc.perform(as(get("/api/follows"), userId)).andExpect(status().isOk()) + return okJson(get("/api/follows"), userId); + } + + private JsonNode players(long userId, String query) throws Exception { + return okJson(get("/api/follows/players").param("q", query), userId); + } + + private JsonNode okJson(MockHttpServletRequestBuilder request, long userId) throws Exception { + String body = mockMvc.perform(as(request, userId)).andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); return JsonMapper.builder().build().readTree(body); } @@ -239,4 +248,61 @@ class FollowControllerTest extends AbstractIntegrationTest { assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue(); } + + @Test + void players_FindsPublicNamesCaseInsensitiveAndNotPrivateBlockedDeleted() throws Exception { + JsonNode json = players(VIEWER, "ER"); + + List names = new ArrayList<>(); + json.get("results").forEach(player -> names.add(player.get("displayName").asString())); + assertThat(names).containsExactlyInAnyOrder("Viewer", "OtherSix"); + assertThat(json.get("results").get(0).propertyNames()).containsExactly("displayName"); + assertThat(players(VIEWER, "re").get("results")).isEmpty(); + assertThat(players(VIEWER, "ed").get("results")).isEmpty(); + } + + @Test + void players_PrefixHitsComeFirst() throws Exception { + name(OTHER + 2, "Zolo"); + jdbc.update("insert into public_user (user_id) values (?)", OTHER + 2); + + JsonNode results = players(VIEWER, "lo").get("results"); + + assertThat(results.get(0).get("displayName").asString()).isEqualTo("Lobo"); + assertThat(results.get(1).get("displayName").asString()).isEqualTo("Zolo"); + } + + @Test + void players_ShortQueryIsEmpty() throws Exception { + assertThat(players(VIEWER, "x").get("results")).isEmpty(); + } + + @Test + void players_EscapesLikeWildcards() throws Exception { + assertThat(players(VIEWER, "%").get("results")).isEmpty(); + assertThat(players(VIEWER, "_o").get("results")).isEmpty(); + } + + @Test + void players_QueryLongerThan100_Is400() throws Exception { + mockMvc.perform(as(get("/api/follows/players").param("q", "x".repeat(101)), VIEWER)) + .andExpect(status().isBadRequest()); + } + + @Test + void players_WithoutLogin_Is401() throws Exception { + mockMvc.perform(get("/api/follows/players").param("q", "lo")).andExpect(status().isUnauthorized()); + } + + @Test + void players_31stSearchInAMinute_Is429() throws Exception { + long searcher = 950_001L; + for (int i = 0; i < 30; i++) { + mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher)).andExpect(status().isOk()); + } + + mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher)) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.code").value("TOO_MANY_SEARCHES")); + } } diff --git a/src/test/java/core_service/modules/follow/service/PlayerSearchLimiterTest.java b/src/test/java/core_service/modules/follow/service/PlayerSearchLimiterTest.java new file mode 100644 index 0000000..6f84b5c --- /dev/null +++ b/src/test/java/core_service/modules/follow/service/PlayerSearchLimiterTest.java @@ -0,0 +1,19 @@ +package core_service.modules.follow.service; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlayerSearchLimiterTest extends AbstractUnitTest { + + @Test + void allow_RefusesTheSearchAfterTheLimitAndCountsEachUserOnItsOwn() { + PlayerSearchLimiter limiter = new PlayerSearchLimiter(2); + + assertThat(limiter.allow(1)).isTrue(); + assertThat(limiter.allow(1)).isTrue(); + assertThat(limiter.allow(1)).isFalse(); + assertThat(limiter.allow(2)).isTrue(); + } +}