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:
lobo committed 2026-09-30 15:00:47 +02:00
1 parent 504c935b27
commit 34d64e7215
23 files changed
+116 -188

No files matched your search

+4
View File
@@ -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"]
+12 -4
View File
@@ -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));
}
}
@@ -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);
} }
+1 -2
View File
@@ -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"
+5 -8
View File
@@ -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);
@@ -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 {
}