Public Access
refactor: item 29 cleanup
C14 C16 C17 C18 C20 A11 A15 X10 X12 X15: dead Steam count code, GameNames, steamIdText, one query per game for the finished estimate, no API versioning config, open-in-view off, CORS from APP_FRONTEND_URL, non-root image, prune only up to the highest known id.
This commit is contained in:
1 parent
504c935b27
commit
34d64e7215
23 files changed
+116
-188
No files matched your search
@@ -36,5 +36,9 @@ EXPOSE 8090
|
|||||||
# Copy the compiled jar file from the builder stage
|
# Copy the compiled jar file from the builder stage
|
||||||
COPY --from=builder /app/target/*.jar app.jar
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
|
|
||||||
|
# No root in the container
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
USER app
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -76,9 +76,9 @@ To Be Played is split into three repositories:
|
|||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
||||||
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Each load replaces
|
||||||
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
|
the key set: a key that left the JWKS stops working. `JWT_PUBLIC_KEY` is used only until the first JWKS load works.
|
||||||
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
|
The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
|
||||||
(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
|
(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
|
||||||
`steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
|
`steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
|
||||||
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`,
|
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`,
|
||||||
@@ -98,11 +98,19 @@ only guard.
|
|||||||
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
|
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
|
||||||
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
|
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
|
||||||
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
|
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
|
||||||
scheduled jobs and background work run without a user and are not checked. A denied call answers `401` without a
|
scheduled jobs and background work run without a user and are not checked. That stays on purpose: the sync
|
||||||
|
worker calls `SteamApiService` and the import worker `ManualGameService`, both `@RequiresRole(USER)`, without a
|
||||||
|
request. Auth denies instead, because it has no such caller. A denied call answers `401` without a
|
||||||
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
|
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
|
||||||
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
|
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
|
||||||
method calling another service's method that needs more.
|
method calling another service's method that needs more.
|
||||||
|
|
||||||
|
**Mirrored in Auth.** These files exist in both services with small differences; a change in one needs the same
|
||||||
|
change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`,
|
||||||
|
`Public` (only Core has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the
|
||||||
|
ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json`
|
||||||
|
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two).
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Method | Path | Auth |
|
| Method | Path | Auth |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class CacheConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CacheManager cacheManager() {
|
public CacheManager cacheManager() {
|
||||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary",
|
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary",
|
||||||
"igdbSearch");
|
"igdbSearch");
|
||||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||||
.expireAfterWrite(10, TimeUnit.MINUTES)
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -90,13 +91,15 @@ public class AccountCleanupRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users that have rows in Core but are not in the list.
|
* Users that have rows in Core but are not in the list. Ids above the highest known one are left out: Auth's
|
||||||
|
* ids never come back, so such a user registered after Auth read the list.
|
||||||
*
|
*
|
||||||
* @param knownUserIds not empty
|
* @param knownUserIds not empty
|
||||||
*/
|
*/
|
||||||
public List<Long> findOrphans(Collection<Long> knownUserIds) {
|
public List<Long> findOrphans(Collection<Long> knownUserIds) {
|
||||||
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
|
String union = String.join(" union ", ORPHAN_SOURCES.stream().map(table -> "select user_id from " + table).toList());
|
||||||
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) order by user_id",
|
return jdbc.queryForList("select user_id from (" + union + ") u where user_id not in (:ids) "
|
||||||
ids(knownUserIds), Long.class);
|
+ "and user_id < :highest order by user_id",
|
||||||
|
ids(knownUserIds).addValue("highest", Collections.max(knownUserIds)), Long.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
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.Optional;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -87,11 +88,8 @@ public class LibraryController {
|
|||||||
@GetMapping("/games-count")
|
@GetMapping("/games-count")
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
principal.steamIdText().ifPresentOrElse(steamId -> syncService.syncIfStale(userId, steamId),
|
||||||
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
|
() -> syncService.stopNightlySync(userId));
|
||||||
} else {
|
|
||||||
syncService.stopNightlySync(userId);
|
|
||||||
}
|
|
||||||
long count = queryService.countOwnedGames(userId);
|
long count = queryService.countOwnedGames(userId);
|
||||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
|
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
|
||||||
}
|
}
|
||||||
@@ -108,9 +106,7 @@ public class LibraryController {
|
|||||||
@RequestParam(defaultValue = "false") boolean hidden
|
@RequestParam(defaultValue = "false") boolean hidden
|
||||||
) {
|
) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||||
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
int safePage = Math.max(0, page);
|
int safePage = Math.max(0, page);
|
||||||
@@ -132,11 +128,12 @@ public class LibraryController {
|
|||||||
|
|
||||||
@PostMapping("/sync")
|
@PostMapping("/sync")
|
||||||
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal.steamId() == null) {
|
Optional<String> steamId = principal.steamIdText();
|
||||||
|
if (steamId.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
|
syncService.forceSync(principal.getIdAsLong(), steamId.get());
|
||||||
return ResponseEntity.accepted().body(Map.of("status", "started"));
|
return ResponseEntity.accepted().body(Map.of("status", "started"));
|
||||||
} catch (SyncCooldownException e) {
|
} catch (SyncCooldownException e) {
|
||||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
@@ -152,8 +149,7 @@ public class LibraryController {
|
|||||||
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable("appid") Long appid) {
|
@PathVariable("appid") Long appid) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null;
|
return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid)
|
||||||
return gameDetailService.getGameDetail(userId, steamId, appid)
|
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(GAME_NOT_FOUND));
|
.body(GAME_NOT_FOUND));
|
||||||
@@ -310,9 +306,7 @@ public class LibraryController {
|
|||||||
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
||||||
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||||
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
|
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game names as the matches compare them (import lines, speedrun.com hits).
|
||||||
|
*/
|
||||||
|
public final class GameNames {
|
||||||
|
|
||||||
|
public static final String TRADEMARK_SIGNS = "[™®©]";
|
||||||
|
|
||||||
|
private GameNames() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower case, without accents, ™ ® © and punctuation, words split by one space.
|
||||||
|
*/
|
||||||
|
public static String normalize(String name) {
|
||||||
|
// Before NFKD, which turns ™ into "TM".
|
||||||
|
String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, "");
|
||||||
|
String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", "");
|
||||||
|
return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,15 @@ import core_service.modules.library.domain.UserGameAchievement;
|
|||||||
import core_service.modules.library.domain.UserGameAchievementId;
|
import core_service.modules.library.domain.UserGameAchievementId;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
|
public interface UserGameAchievementRepository extends JpaRepository<UserGameAchievement, UserGameAchievementId> {
|
||||||
|
|
||||||
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
|
List<UserGameAchievement> findByUserIdAndAppid(Long userId, Long appid);
|
||||||
|
|
||||||
|
List<UserGameAchievement> findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(Long appid,
|
||||||
|
String apiName, Collection<Long> userIds);
|
||||||
|
|
||||||
void deleteByUserIdAndAppid(Long userId, Long appid);
|
void deleteByUserIdAndAppid(Long userId, Long appid);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -39,15 +40,17 @@ public class FinishedEstimateService {
|
|||||||
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
|
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
|
||||||
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
|
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
|
||||||
if (ending.isEmpty()) continue;
|
if (ending.isEmpty()) continue;
|
||||||
String apiName = ending.get().achievement().getApiName();
|
// One query per game, not per user: most candidates never unlocked the ending and come back every night.
|
||||||
|
// With the user ids, the primary key (user_id, appid, api_name) serves it.
|
||||||
|
List<Long> userIds = app.getValue().stream().map(FinishedWithoutDate::userId).toList();
|
||||||
|
Map<Long, Instant> unlockByUser = userAchievementRepository
|
||||||
|
.findByAppidAndApiNameAndUserIdInAndUnlockedTrueAndUnlockedAtNotNull(app.getKey(),
|
||||||
|
ending.get().achievement().getApiName(), userIds).stream()
|
||||||
|
.collect(Collectors.toMap(UserGameAchievement::getUserId, UserGameAchievement::getUnlockedAt));
|
||||||
for (FinishedWithoutDate game : app.getValue()) {
|
for (FinishedWithoutDate game : app.getValue()) {
|
||||||
Optional<UserGameAchievement> unlock = userAchievementRepository
|
Instant unlockedAt = unlockByUser.get(game.userId());
|
||||||
.findByUserIdAndAppid(game.userId(), game.appid()).stream()
|
if (unlockedAt != null) {
|
||||||
.filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName))
|
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), unlockedAt);
|
||||||
.findFirst();
|
|
||||||
if (unlock.isPresent()) {
|
|
||||||
dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(),
|
|
||||||
unlock.get().getUnlockedAt());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameNames;
|
||||||
import core_service.modules.library.domain.GameImport;
|
import core_service.modules.library.domain.GameImport;
|
||||||
import core_service.modules.library.domain.IgdbGame;
|
import core_service.modules.library.domain.IgdbGame;
|
||||||
import core_service.modules.library.repo.GameImportRepository;
|
import core_service.modules.library.repo.GameImportRepository;
|
||||||
@@ -195,7 +196,7 @@ public class GameImportWorker {
|
|||||||
* Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match.
|
* Case, accents, punctuation and marks like ™ ignored, as in the speedrun.com match.
|
||||||
*/
|
*/
|
||||||
static boolean sameName(String a, String b) {
|
static boolean sameName(String a, String b) {
|
||||||
return SpeedrunMatch.normalize(a).equals(SpeedrunMatch.normalize(b));
|
return GameNames.normalize(a).equals(GameNames.normalize(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -212,11 +212,9 @@ public class LibraryQueryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param profileStats the stat group names of the profile ticket
|
* @param profileStats the stat group names of the profile ticket
|
||||||
*/
|
|
||||||
@Public
|
|
||||||
/**
|
|
||||||
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
|
* @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
|
||||||
*/
|
*/
|
||||||
|
@Public
|
||||||
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
|
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
|
||||||
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
|
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
|
||||||
boolean summary = sections.contains(ProfileSection.SUMMARY);
|
boolean summary = sections.contains(ProfileSection.SUMMARY);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameNames;
|
||||||
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
||||||
|
|
||||||
import java.text.Normalizer;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -21,7 +20,6 @@ final class SpeedrunMatch {
|
|||||||
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
|
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
|
||||||
|
|
||||||
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
|
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
|
||||||
private static final String TRADEMARK_SIGNS = "[™®©]";
|
|
||||||
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
|
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
|
||||||
// The column sizes of game_speedrun.
|
// The column sizes of game_speedrun.
|
||||||
private static final int MAX_GAME_ID_LENGTH = 16;
|
private static final int MAX_GAME_ID_LENGTH = 16;
|
||||||
@@ -64,24 +62,14 @@ final class SpeedrunMatch {
|
|||||||
* The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™".
|
* The name for speedrun.com's search, which does not find "DOOM Eternal" for "DOOM Eternal™".
|
||||||
*/
|
*/
|
||||||
static String searchText(String name) {
|
static String searchText(String name) {
|
||||||
return name.replaceAll(TRADEMARK_SIGNS, "").trim();
|
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lower case, without accents, ™ ® © and punctuation, words split by one space.
|
|
||||||
*/
|
|
||||||
static String normalize(String name) {
|
|
||||||
// Before NFKD, which turns ™ into "TM".
|
|
||||||
String withoutMarks = name.replaceAll(TRADEMARK_SIGNS, "");
|
|
||||||
String plain = Normalizer.normalize(withoutMarks, Normalizer.Form.NFKD).replaceAll("\\p{M}", "");
|
|
||||||
return plain.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NameAndYear split(String name) {
|
private static NameAndYear split(String name) {
|
||||||
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
|
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
|
||||||
return matcher.matches()
|
return matcher.matches()
|
||||||
? new NameAndYear(normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
|
? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
|
||||||
: new NameAndYear(normalize(name), null);
|
: new NameAndYear(GameNames.normalize(name), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record NameAndYear(String name, Integer year) {}
|
private record NameAndYear(String name, Integer year) {}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package core_service.modules.steam_adapter.api;
|
|
||||||
|
|
||||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
|
||||||
import core_service.shared.security.UserPrincipal;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/steam")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class SteamController {
|
|
||||||
|
|
||||||
private final SteamApiService steamApiService;
|
|
||||||
|
|
||||||
@GetMapping("/games-count")
|
|
||||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
|
||||||
if (principal.steamId() == null) {
|
|
||||||
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
int count = steamApiService.getOwnedGamesCount(String.valueOf(principal.steamId()));
|
|
||||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -11,7 +11,6 @@ public record SteamOwnedGamesResponse(
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record OwnedGamesResponse(
|
public record OwnedGamesResponse(
|
||||||
@JsonProperty("game_count") Integer gameCount,
|
|
||||||
@JsonProperty("games") List<Game> games
|
@JsonProperty("games") List<Game> games
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@@ -51,51 +51,6 @@ public class SteamApiService {
|
|||||||
@Value("${app.steam.store-country:DE}")
|
@Value("${app.steam.store-country:DE}")
|
||||||
private String storeCountry;
|
private String storeCountry;
|
||||||
|
|
||||||
@Cacheable(value = "steamGameCount", key = "#steamId")
|
|
||||||
@RateLimiter(name = "steamApi")
|
|
||||||
@Retry(name = "steamApiOnce", fallbackMethod = "noGamesCount")
|
|
||||||
public int getOwnedGamesCount(String steamId) {
|
|
||||||
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
|
|
||||||
log.error("Steam API Key is not configured correctly!");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
SteamOwnedGamesResponse response = steamRestClient.get()
|
|
||||||
.uri(uriBuilder -> uriBuilder
|
|
||||||
.path("/IPlayerService/GetOwnedGames/v1/")
|
|
||||||
.queryParam("key", apiKey)
|
|
||||||
.queryParam("steamid", steamId)
|
|
||||||
.queryParam("include_appinfo", "true")
|
|
||||||
.queryParam("include_played_free_games", "true")
|
|
||||||
.queryParam("skip_unvetted_apps", 0)
|
|
||||||
.queryParam("format", "json")
|
|
||||||
.build())
|
|
||||||
.retrieve()
|
|
||||||
.body(SteamOwnedGamesResponse.class);
|
|
||||||
|
|
||||||
if (response != null && response.response() != null) {
|
|
||||||
Integer count = response.response().gameCount();
|
|
||||||
if (count == null) {
|
|
||||||
if (response.response().games() != null) {
|
|
||||||
int size = response.response().games().size();
|
|
||||||
log.info("game_count was null, but games list has {} entries for SteamID: {}", size, steamId);
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
log.warn("Steam API returned an empty response object for SteamID: {}. Check if the ID is correct and 'Game details' are Public.", steamId);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
log.info("Successfully fetched {} games for SteamID: {}", count, steamId);
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fallbacks run after the last try; the callers keep their "Steam failed" rule.
|
|
||||||
private int noGamesCount(String steamId, Exception e) {
|
|
||||||
log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asked on every page load for the avatar in the header.
|
// Asked on every page load for the avatar in the header.
|
||||||
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
||||||
@RateLimiter(name = "steamApi")
|
@RateLimiter(name = "steamApi")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.shared.security;
|
package core_service.shared.security;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param role the active role of this login ("view as"), set by Auth
|
* @param role the active role of this login ("view as"), set by Auth
|
||||||
@@ -20,6 +21,13 @@ public record UserPrincipal(
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Steam ID as the Steam API and user_steam_sync take it; empty, never "null", without one.
|
||||||
|
*/
|
||||||
|
public Optional<String> steamIdText() {
|
||||||
|
return Optional.ofNullable(steamId).map(String::valueOf);
|
||||||
|
}
|
||||||
|
|
||||||
public long getIdAsLong() {
|
public long getIdAsLong() {
|
||||||
return Long.parseLong(id);
|
return Long.parseLong(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ security:
|
|||||||
jwks-url: "http://localhost:8080/.well-known/jwks.json"
|
jwks-url: "http://localhost:8080/.well-known/jwks.json"
|
||||||
|
|
||||||
app:
|
app:
|
||||||
cors:
|
frontend-url: "http://localhost:5173"
|
||||||
allowed-origins: "http://localhost:5173"
|
|
||||||
steam:
|
steam:
|
||||||
api-key: local
|
api-key: local
|
||||||
base-url: "http://localhost:8089/steam"
|
base-url: "http://localhost:8089/steam"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ spring:
|
|||||||
deserialization:
|
deserialization:
|
||||||
accept-float-as-int: false
|
accept-float-as-int: false
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: true
|
open-in-view: false
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
@@ -15,12 +15,6 @@ spring:
|
|||||||
threads:
|
threads:
|
||||||
virtual:
|
virtual:
|
||||||
enabled: true
|
enabled: true
|
||||||
mvc:
|
|
||||||
apiversion:
|
|
||||||
use:
|
|
||||||
query-parameter: version
|
|
||||||
default: "1.0"
|
|
||||||
supported: 1.0, 2,0
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8090
|
port: 8090
|
||||||
@@ -42,8 +36,11 @@ app:
|
|||||||
# Filled from the CI run by the Dockerfile.
|
# Filled from the CI run by the Dockerfile.
|
||||||
commit: ${APP_BUILD_COMMIT:dev}
|
commit: ${APP_BUILD_COMMIT:dev}
|
||||||
time: ${APP_BUILD_TIME:}
|
time: ${APP_BUILD_TIME:}
|
||||||
|
# The frontend; FRONTEND_HOST in the Deployment compose file. The prod host as default: an image that starts before
|
||||||
|
# the compose file sets the env var keeps working.
|
||||||
|
frontend-url: ${APP_FRONTEND_URL:https://tbp.andreas-dahm.eu}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: "https://tbp.andreas-dahm.eu"
|
allowed-origins: ${app.frontend-url}
|
||||||
steam:
|
steam:
|
||||||
api-key: ${STEAM_API_KEY}
|
api-key: ${STEAM_API_KEY}
|
||||||
base-url: https://api.steampowered.com
|
base-url: https://api.steampowered.com
|
||||||
|
|||||||
@@ -111,10 +111,16 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
|||||||
void findOrphans_ReturnsUsersNotInTheList() {
|
void findOrphans_ReturnsUsersNotInTheList() {
|
||||||
repository.addDeleted(List.of(7L));
|
repository.addDeleted(List.of(7L));
|
||||||
|
|
||||||
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L);
|
assertThat(repository.findOrphans(List.of(2L, 8L))).containsExactly(1L, 7L);
|
||||||
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
|
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth reads its ids before the call: a user who registers in between has a higher id and is no orphan.
|
||||||
|
@Test
|
||||||
|
void findOrphans_SkipsUsersAboveTheHighestKnownId() {
|
||||||
|
assertThat(repository.findOrphans(List.of(1L))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
|
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
|
||||||
@Test
|
@Test
|
||||||
void tables_CoverEveryTableWithAUserId() {
|
void tables_CoverEveryTableWithAUserId() {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameNamesTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_DropsCaseAccentsTrademarksAndPunctuation() {
|
||||||
|
assertThat(GameNames.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
|
||||||
|
assertThat(GameNames.normalize("The Witcher 3: Wild Hunt")).isEqualTo(GameNames.normalize("The Witcher® 3 - Wild Hunt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,12 +18,6 @@ class SpeedrunMatchTest extends AbstractUnitTest {
|
|||||||
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
|
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
|
||||||
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
|
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
|
||||||
|
|
||||||
@Test
|
|
||||||
void normalize_DropsCaseAccentsTrademarksAndPunctuation() {
|
|
||||||
assertThat(SpeedrunMatch.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
|
|
||||||
assertThat(SpeedrunMatch.normalize("The Witcher 3: Wild Hunt")).isEqualTo(SpeedrunMatch.normalize("The Witcher® 3 - Wild Hunt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_YearSuffixTellsTheRemakeApart() {
|
void candidates_YearSuffixTellsTheRemakeApart() {
|
||||||
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
|
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
||||||
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(1,
|
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
|
||||||
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
|
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
|
||||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
||||||
.thenAnswer(invocation -> {
|
.thenAnswer(invocation -> {
|
||||||
|
|||||||
@@ -45,27 +45,6 @@ class SteamApiServiceTest {
|
|||||||
ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key");
|
ReflectionTestUtils.setField(steamApiService, "apiKey", "test-api-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void getOwnedGamesCount_shouldReturnCount() {
|
|
||||||
String steamId = "76561197960435530";
|
|
||||||
String jsonResponse = """
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"game_count": 42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
|
|
||||||
.andExpect(queryParam("steamid", steamId))
|
|
||||||
.andExpect(queryParam("key", "test-api-key"))
|
|
||||||
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
int result = steamApiService.getOwnedGamesCount(steamId);
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo(42);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getPlayerSummary_shouldReturnPlayer() {
|
void getPlayerSummary_shouldReturnPlayer() {
|
||||||
String steamId = "76561197960435530";
|
String steamId = "76561197960435530";
|
||||||
@@ -135,15 +114,6 @@ class SteamApiServiceTest {
|
|||||||
assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1);
|
assertThat(steamApiService.getOwnedGames("76561197960435530")).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void getOwnedGamesCount_shouldCountUnvettedApps() {
|
|
||||||
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
|
|
||||||
.andExpect(queryParam("skip_unvetted_apps", "0"))
|
|
||||||
.andRespond(withSuccess("{\"response\":{\"game_count\":1}}", MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
assertThat(steamApiService.getOwnedGamesCount("76561197960435530")).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getOwnedGames_shouldReturnGamesEvenWithNullValues() {
|
void getOwnedGames_shouldReturnGamesEvenWithNullValues() {
|
||||||
String steamId = "76561197960435530";
|
String steamId = "76561197960435530";
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package core_service.shared.test;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Tag;
|
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for WebMvc tests.
|
|
||||||
* Slices the web layer.
|
|
||||||
*/
|
|
||||||
@Tag("WebMvcTest")
|
|
||||||
@WebMvcTest
|
|
||||||
public abstract class AbstractWebMvcTest {
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user