feat(follow): add player search with rate limit (#57)
Build, Test & Push Core Service / quality-check (push) Successful in 2m19s
Build, Test & Push Core Service / docker (push) Successful in 1m34s

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.
This commit is contained in:
lobo committed 2026-10-21 14:54:48 +02:00
1 parent c42526bc16
commit e11fae8e31
12 files changed
+232 -8

No files matched your search

+5 -3
View File
@@ -59,9 +59,10 @@ To Be Played is split into four repositories:
Filters --> Out["games + reason"] Filters --> Out["games + reason"]
``` ```
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most - **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 500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`). 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 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 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 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 | | `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 | | `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 | | `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` (`{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 | | `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 | | `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
@@ -1,14 +1,17 @@
package core_service.modules.follow.api; package core_service.modules.follow.api;
import core_service.modules.follow.api.dto.FollowsDto; 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.FollowEntry;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.service.FollowService; import core_service.modules.follow.service.FollowService;
import core_service.shared.api.ErrorResponse; import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; 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.PathVariable;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@@ -33,6 +37,20 @@ public class FollowController {
return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers()))); 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}") @PutMapping("/by-name/{displayName}")
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("displayName") @Size(max = 100) String displayName) { @PathVariable("displayName") @Size(max = 100) String displayName) {
@@ -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<Player> results) {
/**
* @param displayName the key of the profile URL
*/
public record Player(String displayName) {
}
}
@@ -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<String> names, boolean limited) {
public static PlayerSearch tooMany() {
return new PlayerSearch(List.of(), true);
}
public static PlayerSearch of(List<String> names) {
return new PlayerSearch(names, false);
}
}
@@ -1,6 +1,7 @@
package core_service.modules.follow.repo; package core_service.modules.follow.repo;
import core_service.modules.follow.domain.FollowEntry; import core_service.modules.follow.domain.FollowEntry;
import core_service.shared.sql.LikePattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@@ -20,6 +21,7 @@ import java.util.Optional;
public class FollowRepository { public class FollowRepository {
static final int LIST_LIMIT = 500; static final int LIST_LIMIT = 500;
public static final int SEARCH_LIMIT = 20;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> { private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
Timestamp lastActivityAt = rs.getTimestamp("last_activity_at"); 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(); 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<String> 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. * Checks the visibility again in the same statement, so a user who went private in between gets no follower.
* *
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
import core_service.modules.follow.domain.FollowResult; import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows; import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.repo.FollowRepository; import core_service.modules.follow.repo.FollowRepository;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
@@ -17,8 +19,10 @@ import java.util.Optional;
public class FollowService { public class FollowService {
static final int MAX_FOLLOWING = 500; static final int MAX_FOLLOWING = 500;
static final int MIN_QUERY_LENGTH = 2;
private final FollowRepository followRepository; private final FollowRepository followRepository;
private final PlayerSearchLimiter limiter;
/** /**
* Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed. * 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) { public Follows follows(long userId) {
return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(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));
}
} }
@@ -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<Long, RateLimiter> 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();
}
}
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.shared.sql.LikePattern;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -28,7 +29,6 @@ import java.util.EnumMap;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> { public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
@@ -448,9 +448,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
static String containsPattern(String query) { static String containsPattern(String query) {
if (query == null || query.isBlank()) return null; if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT) return "%" + LikePattern.escapeLower(query.trim()) + "%";
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
} }
/** /**
@@ -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("_", "\\_");
}
}
+2
View File
@@ -106,6 +106,8 @@ app:
# The hand changes of a followed user are one batch after this quiet time. # The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000 digest-ms: 600000
quiet-minutes: 10 quiet-minutes: 10
# Player searches per user and minute; more answers 429.
search-per-minute: 30
inbox: inbox:
purge-cron: "0 15 3 * * *" purge-cron: "0 15 3 * * *"
keep-days: 90 keep-days: 90
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import tools.jackson.databind.JsonNode; import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -83,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest {
} }
private JsonNode list(long userId) throws Exception { 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(); .andReturn().getResponse().getContentAsString();
return JsonMapper.builder().build().readTree(body); 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(); assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue();
} }
@Test
void players_FindsPublicNamesCaseInsensitiveAndNotPrivateBlockedDeleted() throws Exception {
JsonNode json = players(VIEWER, "ER");
List<String> 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"));
}
} }
@@ -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();
}
}