Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 3c2f40036d feat(library): let auditors correct game data
Build, Test & Push Core Service / docker (push) Successful in 1m25s
Build, Test & Push Core Service / quality-check (push) Successful in 1m0s
An AUDITOR corrects name, header image, speedrun link, moods, endless and genres of a game for every user (V20 game_correction, PUT/DELETE /api/library/games/{appid}/corrections/{field}). The value is also written into the cached row, so reads stay unchanged; the Steam sync, the image repair, the IGDB add and the mood and speedrun upserts skip corrected fields. SteamGame gets @DynamicUpdate so a sync no longer writes back columns it did not change.
2026-09-28 09:31:39 +02:00
lobo 2596df609e feat(security): check role markers on services and read the role claim
Every service method a controller calls carries @RequiresRole, @Public or @Internal; a Spring Security method interceptor checks it while a request is served. Core now grants the active role from the token and every lower one. ArchUnit fails the build for an unmarked method, two markers, a controller calling an @Async method, or a service reaching a method that needs more. The background refreshes moved from LibraryController into the services, because an @Async method is never checked.
2026-09-28 08:50:10 +02:00
72 changed files with 2180 additions and 41 deletions

No files matched your search

+23 -1
View File
@@ -70,7 +70,9 @@ To Be Played is split into three repositories:
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). Loaded keys stay
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId` and `steamId` from the claims. `tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId`, `steamId` and the active
`role` (missing or unknown = USER) from the claims and grants that role and every lower one
(`USER < AUDITOR < PLATFORM_ADMIN`).
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin. frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
@@ -79,12 +81,21 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
only guard. only guard.
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
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
scheduled jobs and `@Async` work run without a user and are not checked. 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
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.
## API ## API
| Method | Path | Auth | | Method | Path | Auth |
|---|---|---| |---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` | JWT | | `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT | | `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | | `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
@@ -196,6 +207,17 @@ The frontend polls `GET /api/library/sync-status` until the run ends.
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns. `broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
### Corrections by an auditor
An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag and genres for
every user (`game_correction`, V20). The value is also written into the cached row (`steam_game`, `game_speedrun`,
`game_mood`), so no read joins the corrections. The refresh writers skip a corrected field: the Steam sync (name),
the image repair (image), the IGDB add (name, image), the mood and speedrun upserts (SQL). **A new writer of these
fields must do the same.** "Back to source" (`DELETE`) writes back the source value from the first correction and
lets the next refresh ask IGDB or speedrun.com again. An image must be on a host of the frontend's CSP.
Known limit: a mood or speedrun refresh that waits on the row lock while an auditor saves can still write the
source value once (its guard sees the state from before the save); the auditor saves again.
### Deleted accounts ### Deleted accounts
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
@@ -1,9 +1,12 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.Role;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -19,6 +22,14 @@ final class AccessTokens {
private AccessTokens() { private AccessTokens() {
} }
/**
* The active role and every role below it, so a check for USER also lets an admin in.
*/
static List<SimpleGrantedAuthority> authorities(Role role) {
return Arrays.stream(Role.values()).filter(role::atLeast)
.map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList();
}
static Stream<String> candidates(HttpServletRequest request) { static Stream<String> candidates(HttpServletRequest request) {
return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream); return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream);
} }
@@ -16,7 +16,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -49,7 +48,7 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal,
null, null,
Collections.emptyList() AccessTokens.authorities(principal.role())
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.Optional; import java.util.Optional;
@Component("jwtAuthenticationFilter") @Component("jwtAuthenticationFilter")
@@ -40,7 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal.get(), principal.get(),
null, null,
Collections.emptyList() AccessTokens.authorities(principal.get().role())
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
@@ -2,6 +2,7 @@ package core_service.configuration.security;
import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.ProfileTicketVerifier;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.JwtParser;
@@ -23,6 +24,7 @@ public class JwtService implements ProfileTicketVerifier {
private static final String TYPE_CLAIM = "typ"; private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile"; private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service"; private static final String SERVICE_TOKEN_TYPE = "service";
private static final String ROLE_CLAIM = "role";
private final JwtParser parser; private final JwtParser parser;
@@ -45,7 +47,8 @@ public class JwtService implements ProfileTicketVerifier {
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) { if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class))); return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class),
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -0,0 +1,34 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.RequiresRole;
import org.springframework.aop.Advisor;
import org.springframework.aop.Pointcut;
import org.springframework.aop.support.Pointcuts;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Runs {@link RoleAuthorizationManager} before every method with a role marker, or of a class with one. A method
* that is only {@code @Public} needs no check.
*/
@Configuration
@EnableMethodSecurity(prePostEnabled = false)
public class MethodSecurityConfig {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static Advisor roleAuthorization() {
Pointcut marked = Pointcuts.union(
Pointcuts.union(new AnnotationMatchingPointcut(RequiresRole.class, true),
new AnnotationMatchingPointcut(null, RequiresRole.class, true)),
Pointcuts.union(new AnnotationMatchingPointcut(Internal.class, true),
new AnnotationMatchingPointcut(null, Internal.class, true)));
return new AuthorizationManagerBeforeMethodInterceptor(marked, new RoleAuthorizationManager());
}
}
@@ -0,0 +1,57 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.context.request.RequestContextHolder;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.util.function.Supplier;
class RoleAuthorizationManager implements AuthorizationManager<MethodInvocation> {
private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true);
private static final AuthorizationDecision DENIED = new AuthorizationDecision(false);
@Override
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
if (RequestContextHolder.getRequestAttributes() == null) {
return GRANTED;
}
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
: invocation.getMethod().getDeclaringClass();
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
AnnotatedElement marked = isMarked(method) ? method : targetClass;
if (AnnotatedElementUtils.hasAnnotation(marked, Public.class)) {
return GRANTED;
}
if (AnnotatedElementUtils.hasAnnotation(marked, Internal.class)) {
return hasAuthority(authentication.get(), ServiceTokenFilter.SERVICE_AUTHORITY) ? GRANTED : DENIED;
}
RequiresRole required = AnnotatedElementUtils.findMergedAnnotation(marked, RequiresRole.class);
if (required == null) {
return DENIED;
}
return hasAuthority(authentication.get(), "ROLE_" + required.value().name()) ? GRANTED : DENIED;
}
private static boolean isMarked(Method method) {
return AnnotatedElementUtils.hasAnnotation(method, RequiresRole.class)
|| AnnotatedElementUtils.hasAnnotation(method, Public.class)
|| AnnotatedElementUtils.hasAnnotation(method, Internal.class);
}
private static boolean hasAuthority(Authentication authentication, String authority) {
return authentication != null && authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).anyMatch(authority::equals);
}
}
@@ -2,6 +2,7 @@ package core_service.modules.account.service;
import core_service.modules.account.domain.CleanupResult; import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.repo.AccountCleanupRepository; import core_service.modules.account.repo.AccountCleanupRepository;
import core_service.shared.security.Internal;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
@@ -16,6 +17,7 @@ import java.util.Map;
* Auth decides who is deleted and when; Core only checks that a purged user is tombstoned. * Auth decides who is deleted and when; Core only checks that a purged user is tombstoned.
*/ */
@Service @Service
@Internal
@Slf4j @Slf4j
public class AccountCleanupService { public class AccountCleanupService {
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport; import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CorrectionDto;
import core_service.modules.library.api.dto.FinishedHintDismissal; import core_service.modules.library.api.dto.FinishedHintDismissal;
import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.HiddenUpdateDto; import core_service.modules.library.api.dto.HiddenUpdateDto;
@@ -19,12 +20,12 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameCorrectionService;
import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.ImageRepairService; import core_service.modules.library.service.ImageRepairService;
import core_service.modules.library.service.InvalidCorrectionException;
import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService; import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.SyncCooldownException;
import core_service.modules.library.service.UserStatsService; import core_service.modules.library.service.UserStatsService;
@@ -37,6 +38,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; 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.GetMapping; 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.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -70,10 +72,9 @@ public class LibraryController {
private final GameDetailService gameDetailService; private final GameDetailService gameDetailService;
private final BacklogService backlogService; private final BacklogService backlogService;
private final SteamProfileService steamProfileService; private final SteamProfileService steamProfileService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
private final ImageRepairService imageRepairService; private final ImageRepairService imageRepairService;
private final UserStatsService userStatsService; private final UserStatsService userStatsService;
private final GameCorrectionService gameCorrectionService;
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -160,6 +161,41 @@ public class LibraryController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* An auditor's correction, for every user. 204; the game details show the new value.
*/
@PutMapping("/games/{appid}/corrections/{field}")
public ResponseEntity<?> correctGame(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid, @PathVariable("field") String field,
@RequestBody CorrectionDto correction) {
try {
return correctionAnswer(gameCorrectionService.correct(appid, field, correction.value(), principal.getIdAsLong()));
} catch (InvalidCorrectionException e) {
return invalidCorrection(e.getMessage());
}
}
/**
* Back to the source's value. 204 also when the field had no correction.
*/
@DeleteMapping("/games/{appid}/corrections/{field}")
public ResponseEntity<?> resetCorrection(@PathVariable("appid") Long appid, @PathVariable("field") String field) {
try {
return correctionAnswer(gameCorrectionService.reset(appid, field));
} catch (InvalidCorrectionException e) {
return invalidCorrection(e.getMessage());
}
}
private static ResponseEntity<?> correctionAnswer(GameCorrectionService.Result result) {
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game"));
}
private static ResponseEntity<?> invalidCorrection(String message) {
return ResponseEntity.badRequest().body(Map.of("code", "INVALID_CORRECTION", "message", message));
}
@PutMapping("/games/{appid}/backlog") @PutMapping("/games/{appid}/backlog")
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid, @PathVariable("appid") Long appid,
@@ -241,11 +277,6 @@ public class LibraryController {
if (principal.steamId() != null) { if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId())); syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
} }
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
} }
@@ -257,10 +288,7 @@ public class LibraryController {
@RequestParam(required = false) Set<StatGroup> groups) { @RequestParam(required = false) Set<StatGroup> groups) {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
Set<StatGroup> asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups; Set<StatGroup> asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups;
if (asked.contains(StatGroup.TASTE)) { return ResponseEntity.ok(userStatsService.getOwnStats(userId, asked));
moodService.refreshListedInBackground(userId);
}
return ResponseEntity.ok(userStatsService.getStats(userId, asked));
} }
@GetMapping("/sync-status") @GetMapping("/sync-status")
@@ -0,0 +1,6 @@
package core_service.modules.library.api.dto;
/**
* @param value a string (name, image, speedrun), a list of names (moods, genres), a boolean (endless), or null
*/
public record CorrectionDto(Object value) {}
@@ -0,0 +1,7 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.CorrectionField;
import java.time.Instant;
public record CorrectionMarkDto(CorrectionField field, Instant correctedAt) {}
@@ -35,7 +35,15 @@ public record GameDetailDto(
// see GameDto#hidden // see GameDto#hidden
boolean hidden, boolean hidden,
// null when there is nothing to ask // null when there is nothing to ask
FinishedHintDto finishedHint FinishedHintDto finishedHint,
// Mood names without SPEEDRUN (that is speedrunUrl); empty until IGDB was asked
List<String> moods,
// null when not known
Boolean endless,
// IGDB genre names
List<String> genres,
// the fields an auditor corrected
List<CorrectionMarkDto> corrections
) { ) {
/** /**
@@ -0,0 +1,15 @@
package core_service.modules.library.domain;
import java.util.Arrays;
import java.util.Optional;
public enum CorrectionField {
NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES;
/**
* @param name the lower-case name in the URL, e.g. "speedrun"
*/
public static Optional<CorrectionField> fromPath(String name) {
return Arrays.stream(values()).filter(field -> field.name().equalsIgnoreCase(name)).findFirst();
}
}
@@ -0,0 +1,55 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
/**
* An auditor's correction of one field of a game, for every user. See V20 for how it is applied.
*/
@Entity
@Table(name = "game_correction")
@IdClass(GameCorrectionId.class)
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameCorrection {
@Id
@Column(name = "appid")
private Long appid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "field")
private CorrectionField field;
/** JSON; null (the JSON text, not SQL NULL) is a valid value, e.g. "no speedrun". */
@Column(name = "value")
private String value;
@Column(name = "source_value")
private String sourceValue;
@Column(name = "corrected_by", nullable = false)
private Long correctedBy;
@Column(name = "corrected_at", nullable = false)
private Instant correctedAt;
public GameCorrection(Long appid, CorrectionField field, String sourceValue) {
this.appid = appid;
this.field = field;
this.sourceValue = sourceValue;
}
}
@@ -0,0 +1,19 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class GameCorrectionId implements Serializable {
private Long appid;
private CorrectionField field;
}
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.AccessLevel; import lombok.AccessLevel;
import org.hibernate.annotations.DynamicUpdate;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
@@ -12,6 +13,8 @@ import lombok.Setter;
import java.time.Instant; import java.time.Instant;
@Entity @Entity
// Only changed columns: a sync touching the icon must not write back a name or image an auditor changed meanwhile.
@DynamicUpdate
@Table(name = "steam_game") @Table(name = "steam_game")
@Getter @Getter
@Setter @Setter
@@ -0,0 +1,35 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameCorrection;
import core_service.modules.library.domain.GameCorrectionId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Set;
public interface GameCorrectionRepository extends JpaRepository<GameCorrection, GameCorrectionId> {
List<GameCorrection> findByAppidOrderByField(Long appid);
@Query("select c.appid from GameCorrection c where c.field = :field and c.appid in :appids")
Set<Long> findCorrectedAppids(@Param("field") CorrectionField field, @Param("appids") Collection<Long> appids);
/**
* source_value only on the first correction: it stays what the source had before any auditor.
*/
@Modifying
@Query(value = "insert into game_correction (appid, field, value, source_value, corrected_by, corrected_at) "
+ "values (:appid, :field, :value, :sourceValue, :correctedBy, :correctedAt) "
+ "on conflict (appid, field) do update set value = excluded.value, "
+ "corrected_by = excluded.corrected_by, corrected_at = excluded.corrected_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("field") String field, @Param("value") String value,
@Param("sourceValue") String sourceValue, @Param("correctedBy") Long correctedBy,
@Param("correctedAt") Instant correctedAt);
}
@@ -20,14 +20,50 @@ public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
* @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits * @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits
* @param genres IGDB genre names; empty when IGDB has none * @param genres IGDB genre names; empty when IGDB has none
*/ */
// An upsert, not save(): two users' syncs can store the same new game at the same time. // An upsert, not save(): two users' syncs can store the same new game at the same time. A corrected column
// keeps the auditor's value.
@Modifying @Modifying
@Transactional @Transactional
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " + @Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), cast(:genres as varchar(64)[]), :endless, :checkedAt) " + "values (:appid, cast(:moods as varchar(16)[]), cast(:genres as varchar(64)[]), :endless, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, genres = excluded.genres, " + "on conflict (appid) do update set " +
"endless = excluded.endless, checked_at = excluded.checked_at", "moods = case when " + CORRECTED + "'MOODS') then game_mood.moods else excluded.moods end, " +
"genres = case when " + CORRECTED + "'GENRES') then game_mood.genres else excluded.genres end, " +
"endless = case when " + CORRECTED + "'ENDLESS') then game_mood.endless else excluded.endless end, " +
"checked_at = excluded.checked_at",
nativeQuery = true) nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("genres") String[] genres, void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("genres") String[] genres,
@Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt); @Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_mood.appid and c.field = ";
// A game IGDB never answered for has no row yet; it gets one with nothing known but the corrected column, and
// checkedAt only for that new row (stale, so the next refresh fills the other columns).
@Modifying
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), '{}', null, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods",
nativeQuery = true)
void writeMoods(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("checkedAt") Instant checkedAt);
@Modifying
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, '{}', cast(:genres as varchar(64)[]), null, :checkedAt) " +
"on conflict (appid) do update set genres = excluded.genres",
nativeQuery = true)
void writeGenres(@Param("appid") Long appid, @Param("genres") String[] genres, @Param("checkedAt") Instant checkedAt);
@Modifying
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, '{}', '{}', :endless, :checkedAt) " +
"on conflict (appid) do update set endless = excluded.endless",
nativeQuery = true)
void writeEndless(@Param("appid") Long appid, @Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
/**
* The next mood refresh asks IGDB again.
*/
@Modifying
@Query("update GameMoods m set m.checkedAt = :longAgo where m.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
} }
@@ -16,13 +16,36 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
@Query("select s.appid from GameSpeedrun s where s.appid in :appids and s.checkedAt > :since") @Query("select s.appid from GameSpeedrun s where s.appid in :appids and s.checkedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since); Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
// A corrected link keeps the auditor's value.
@Modifying @Modifying
@Transactional @Transactional
@Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " + @Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " +
"values (:appid, :speedrunGameId, :weblink, :checkedAt) " + "values (:appid, :speedrunGameId, :weblink, :checkedAt) " +
"on conflict (appid) do update set speedrun_game_id = excluded.speedrun_game_id, " + "on conflict (appid) do update set " +
"weblink = excluded.weblink, checked_at = excluded.checked_at", "speedrun_game_id = case when " + CORRECTED + " then game_speedrun.speedrun_game_id " +
"else excluded.speedrun_game_id end, " +
"weblink = case when " + CORRECTED + " then game_speedrun.weblink else excluded.weblink end, " +
"checked_at = excluded.checked_at",
nativeQuery = true) nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("speedrunGameId") String speedrunGameId, void upsert(@Param("appid") Long appid, @Param("speedrunGameId") String speedrunGameId,
@Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt); @Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt);
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_speedrun.appid and c.field = 'SPEEDRUN')";
/**
* An auditor's link has no speedrun.com game id.
*/
@Modifying
@Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " +
"values (:appid, null, :weblink, :checkedAt) " +
"on conflict (appid) do update set speedrun_game_id = null, weblink = excluded.weblink",
nativeQuery = true)
void writeWeblink(@Param("appid") Long appid, @Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt);
/**
* The next speedrun check asks speedrun.com again.
*/
@Modifying
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
} }
@@ -9,8 +9,17 @@ import org.springframework.transaction.annotation.Transactional;
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> { public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
// Only for a name from Steam or IGDB: an auditor's name stays.
@Modifying @Modifying
@Transactional @Transactional
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid") @Query("update SteamGame g set g.name = :name where g.appid = :appid and not exists "
+ "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.NAME)")
void setSourceName(@Param("appid") Long appid, @Param("name") String name);
@Modifying
@Transactional
// An auditor's image stays.
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid and not exists "
+ "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.IMAGE)")
void setImageUrl(@Param("appid") Long appid, @Param("imageUrl") String imageUrl); void setImageUrl(@Param("appid") Long appid, @Param("imageUrl") String imageUrl);
} }
@@ -7,6 +7,8 @@ import core_service.modules.library.domain.GameStatus;
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.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; 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;
@@ -15,6 +17,7 @@ import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
public class BacklogService { public class BacklogService {
@@ -0,0 +1,204 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameCorrection;
import core_service.modules.library.domain.GameCorrectionId;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.databind.json.JsonMapper;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* An auditor's corrections of shared game data; V20 says how they are applied.
*/
@Service
@RequiresRole(Role.AUDITOR)
@RequiredArgsConstructor
public class GameCorrectionService {
public enum Result { DONE, NOT_FOUND }
// The image hosts the frontend's Content Security Policy allows (nginx.conf img-src).
private static final Set<String> IMAGE_HOSTS = Set.of("steamcdn-a.akamaihd.net", "media.steampowered.com",
"images.igdb.com");
private static final String IMAGE_HOST_SUFFIX = ".steamstatic.com";
private static final int MAX_TEXT = 512;
private static final int MAX_GENRES = 10;
private static final int MAX_GENRE_LENGTH = 64;
// Far enough back that the next refresh asks the source again.
private static final Instant LONG_AGO = Instant.EPOCH;
private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository correctionRepository;
private final GameMoodsRepository gameMoodsRepository;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final JsonMapper jsonMapper;
/**
* @param fieldName see {@link CorrectionField#fromPath}
* @param value a string, a string list, a boolean or null, as the field needs
* @throws InvalidCorrectionException for an unknown field or a value that does not fit it
*/
@Transactional
public Result correct(long appid, String fieldName, Object value, long auditorId) {
CorrectionField field = field(fieldName);
Optional<SteamGame> game = steamGameRepository.findById(appid);
if (game.isEmpty()) {
return Result.NOT_FOUND;
}
Object clean = validate(field, value);
// One statement, not find-then-save: two auditors' first correction of a field must not collide on the key.
correctionRepository.upsert(appid, field.name(), jsonMapper.writeValueAsString(clean),
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
write(field, game.get(), clean);
return Result.DONE;
}
/**
* Back to the source: the value the source had at the first correction. Moods and the speedrun link are also
* asked again at the next refresh.
*/
@Transactional
public Result reset(long appid, String fieldName) {
CorrectionField field = field(fieldName);
Optional<SteamGame> game = steamGameRepository.findById(appid);
if (game.isEmpty()) {
return Result.NOT_FOUND;
}
Optional<GameCorrection> correction = correctionRepository.findById(new GameCorrectionId(appid, field));
if (correction.isEmpty()) {
return Result.DONE;
}
correctionRepository.delete(correction.get());
write(field, game.get(), jsonMapper.readValue(correction.get().getSourceValue(), Object.class));
switch (field) {
case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO);
case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO);
case NAME, IMAGE -> { }
}
return Result.DONE;
}
// Here, not in the controller: a USER gets 403 before learning which fields exist.
private static CorrectionField field(String name) {
return CorrectionField.fromPath(name).orElseThrow(() -> new InvalidCorrectionException("unknown field " + name));
}
private Object cachedValue(CorrectionField field, SteamGame game) {
return switch (field) {
case NAME -> game.getName();
case IMAGE -> game.getImageUrl();
case SPEEDRUN -> gameSpeedrunRepository.findById(game.getAppid()).map(GameSpeedrun::getWeblink).orElse(null);
case MOODS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getMoods).orElse(List.of());
case GENRES -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getGenres).orElse(List.of());
case ENDLESS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getEndless).orElse(null);
};
}
private void write(CorrectionField field, SteamGame game, Object value) {
switch (field) {
case NAME -> game.setName((String) value);
case IMAGE -> game.setImageUrl((String) value);
// A new row has only the corrected link, so the next check would ask speedrun.com for nothing.
case SPEEDRUN -> gameSpeedrunRepository.writeWeblink(game.getAppid(), (String) value, Instant.now());
case MOODS -> gameMoodsRepository.writeMoods(game.getAppid(), strings(value), LONG_AGO);
case GENRES -> gameMoodsRepository.writeGenres(game.getAppid(), strings(value), LONG_AGO);
case ENDLESS -> gameMoodsRepository.writeEndless(game.getAppid(), (Boolean) value, LONG_AGO);
}
}
private static String[] strings(Object list) {
return ((List<?>) list).stream().map(String.class::cast).toArray(String[]::new);
}
static Object validate(CorrectionField field, Object value) {
return switch (field) {
case NAME -> {
if (!(value instanceof String name) || name.isBlank() || name.strip().length() > MAX_TEXT) {
throw new InvalidCorrectionException("name must be 1 to " + MAX_TEXT + " characters");
}
yield name.strip();
}
case IMAGE -> {
if (value == null) {
yield null;
}
if (!(value instanceof String url) || url.length() > MAX_TEXT || !isAllowedImage(url)) {
throw new InvalidCorrectionException("image must be an https URL on " + IMAGE_HOSTS
+ " or *" + IMAGE_HOST_SUFFIX);
}
yield url;
}
case SPEEDRUN -> {
if (value == null) {
yield null;
}
if (!(value instanceof String url) || !SpeedrunMatch.isWeblink(url)) {
throw new InvalidCorrectionException("speedrun must be a page on " + SpeedrunMatch.WEBLINK_PREFIX);
}
yield url;
}
case MOODS -> textList(value, "moods").stream()
.map(GameCorrectionService::storedMood).distinct().sorted().toList();
case GENRES -> {
List<String> genres = textList(value, "genres").stream().map(String::strip).distinct().sorted().toList();
if (genres.size() > MAX_GENRES || genres.stream().anyMatch(g -> g.isEmpty() || g.length() > MAX_GENRE_LENGTH)) {
throw new InvalidCorrectionException("genres: at most " + MAX_GENRES + " names of 1 to "
+ MAX_GENRE_LENGTH + " characters");
}
yield genres;
}
case ENDLESS -> {
if (value != null && !(value instanceof Boolean)) {
throw new InvalidCorrectionException("endless must be true, false or null");
}
yield value;
}
};
}
private static List<String> textList(Object value, String name) {
if (!(value instanceof List<?> list) || list.stream().anyMatch(item -> !(item instanceof String))) {
throw new InvalidCorrectionException(name + " must be a list of names");
}
return list.stream().map(String.class::cast).toList();
}
private static boolean isAllowedImage(String url) {
try {
URI uri = new URI(url);
String host = uri.getHost();
return "https".equals(uri.getScheme()) && host != null
&& (IMAGE_HOSTS.contains(host) || host.endsWith(IMAGE_HOST_SUFFIX));
} catch (URISyntaxException e) {
return false;
}
}
// SPEEDRUN comes from the speedrun link, never from the stored moods.
private static String storedMood(String name) {
boolean known = Arrays.stream(Mood.values()).anyMatch(mood -> mood != Mood.SPEEDRUN && mood.name().equals(name));
if (!known) {
throw new InvalidCorrectionException("moods: unknown mood " + name + " (SPEEDRUN is the speedrun link)");
}
return name;
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto; import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.FinishedHintDto; import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.AchievementProgress;
@@ -17,6 +18,7 @@ import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -25,6 +27,8 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -39,6 +43,7 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class GameDetailService { public class GameDetailService {
@@ -56,6 +61,7 @@ public class GameDetailService {
private final GameSpeedrunRepository gameSpeedrunRepository; private final GameSpeedrunRepository gameSpeedrunRepository;
private final SpeedrunService speedrunService; private final SpeedrunService speedrunService;
private final GameMoodsRepository gameMoodsRepository; private final GameMoodsRepository gameMoodsRepository;
private final GameCorrectionRepository gameCorrectionRepository;
@Value("${app.library.achievement-ttl-hours:1}") @Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours; private long achievementTtlHours;
@@ -153,10 +159,11 @@ public class GameDetailService {
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid()); Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName) Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
Optional<GameMoods> moods = gameMoodsRepository.findById(userGame.getAppid());
FinishedHintDto finishedHint = FinishedHint.of(new FinishedHint.Facts(userGame.getStatus(), userGame.getSource(), FinishedHintDto finishedHint = FinishedHint.of(new FinishedHint.Facts(userGame.getStatus(), userGame.getSource(),
schema, unlockedNames, statsVisible, userGame.getPlaytimeMinutes(), schema, unlockedNames, statsVisible, userGame.getPlaytimeMinutes(),
timeToBeat.map(GameTimeToBeat::getNormallyMinutes).orElse(null), timeToBeat.map(GameTimeToBeat::getNormallyMinutes).orElse(null),
gameMoodsRepository.findById(userGame.getAppid()).map(GameMoods::getEndless).orElse(null), moods.map(GameMoods::getEndless).orElse(null),
userGame.getFinishedHintDismissed())) userGame.getFinishedHintDismissed()))
.map(hint -> new FinishedHintDto(hint.type(), hint.achievement())) .map(hint -> new FinishedHintDto(hint.type(), hint.achievement()))
.orElse(null); .orElse(null);
@@ -190,7 +197,12 @@ public class GameDetailService {
meta != null ? meta.getImageUrl() : null, meta != null ? meta.getImageUrl() : null,
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null), gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null),
userGame.getHiddenAt() != null, userGame.getHiddenAt() != null,
finishedHint finishedHint,
moods.map(GameMoods::getMoods).orElse(List.of()),
moods.map(GameMoods::getEndless).orElse(null),
moods.map(GameMoods::getGenres).orElse(List.of()),
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()
); );
} }
} }
@@ -1,6 +1,7 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage; import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameKeys; import core_service.modules.library.domain.GameKeys;
@@ -9,10 +10,13 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository; import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.modules.steam_adapter.service.SteamCdnService; import core_service.modules.steam_adapter.service.SteamCdnService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@@ -35,12 +39,14 @@ import java.util.stream.Collectors;
* the job checks the picture itself first, so a false report (ad blocker, bad network) changes nothing. * the job checks the picture itself first, so a false report (ad blocker, bad network) changes nothing.
*/ */
@Service @Service
@RequiresRole(Role.USER)
@Slf4j @Slf4j
public class ImageRepairService { public class ImageRepairService {
private final BrokenImageRepository brokenImageRepository; private final BrokenImageRepository brokenImageRepository;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository gameCorrectionRepository;
private final GameAchievementSchemaRepository schemaRepository; private final GameAchievementSchemaRepository schemaRepository;
private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final AchievementSyncService achievementSyncService; private final AchievementSyncService achievementSyncService;
@@ -56,6 +62,7 @@ public class ImageRepairService {
public ImageRepairService(BrokenImageRepository brokenImageRepository, public ImageRepairService(BrokenImageRepository brokenImageRepository,
UserGameRepository userGameRepository, UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository, SteamGameRepository steamGameRepository,
GameCorrectionRepository gameCorrectionRepository,
GameAchievementSchemaRepository schemaRepository, GameAchievementSchemaRepository schemaRepository,
GameAchievementSchemaMetaRepository schemaMetaRepository, GameAchievementSchemaMetaRepository schemaMetaRepository,
AchievementSyncService achievementSyncService, AchievementSyncService achievementSyncService,
@@ -68,6 +75,7 @@ public class ImageRepairService {
this.brokenImageRepository = brokenImageRepository; this.brokenImageRepository = brokenImageRepository;
this.userGameRepository = userGameRepository; this.userGameRepository = userGameRepository;
this.steamGameRepository = steamGameRepository; this.steamGameRepository = steamGameRepository;
this.gameCorrectionRepository = gameCorrectionRepository;
this.schemaRepository = schemaRepository; this.schemaRepository = schemaRepository;
this.schemaMetaRepository = schemaMetaRepository; this.schemaMetaRepository = schemaMetaRepository;
this.achievementSyncService = achievementSyncService; this.achievementSyncService = achievementSyncService;
@@ -118,8 +126,10 @@ public class ImageRepairService {
private void repairCovers(List<BrokenImage> rows, Instant startedAt) { private void repairCovers(List<BrokenImage> rows, Instant startedAt) {
if (rows.isEmpty()) return; if (rows.isEmpty()) return;
Map<Long, SteamGame> games = steamGameRepository.findAllById(rows.stream().map(BrokenImage::getAppid).toList()) List<Long> appids = rows.stream().map(BrokenImage::getAppid).toList();
.stream().collect(Collectors.toMap(SteamGame::getAppid, Function.identity())); Map<Long, SteamGame> games = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, Function.identity()));
Set<Long> corrected = gameCorrectionRepository.findCorrectedAppids(CorrectionField.IMAGE, appids);
List<BrokenImage> broken = new ArrayList<>(); List<BrokenImage> broken = new ArrayList<>();
for (BrokenImage row : rows) { for (BrokenImage row : rows) {
SteamGame game = games.get(row.getAppid()); SteamGame game = games.get(row.getAppid());
@@ -127,6 +137,10 @@ public class ImageRepairService {
? game.getImageUrl() : steamImageBaseUrl + row.getAppid() + "/header.jpg"; ? game.getImageUrl() : steamImageBaseUrl + row.getAppid() + "/header.jpg";
if (steamCdnService.isAvailable(current)) { if (steamCdnService.isAvailable(current)) {
delete(row); delete(row);
} else if (corrected.contains(row.getAppid())) {
// An auditor chose this image; setImageUrl keeps it, so only an auditor can fix it.
log.warn("Corrected cover of app {} does not load: {}", row.getAppid(), current);
markChecked(row, startedAt);
} else { } else {
broken.add(row); broken.add(row);
} }
@@ -0,0 +1,8 @@
package core_service.modules.library.service;
public class InvalidCorrectionException extends RuntimeException {
public InvalidCorrectionException(String message) {
super(message);
}
}
@@ -28,6 +28,9 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository; import core_service.modules.library.repo.WishlistGameRepository;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -50,6 +53,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class LibraryQueryService { public class LibraryQueryService {
@@ -68,6 +72,8 @@ public class LibraryQueryService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService; private final UserStatsService userStatsService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@@ -86,7 +92,15 @@ public class LibraryQueryService {
return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
} }
/**
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*/
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) { public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count))); return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count)));
} }
@@ -197,6 +211,7 @@ public class LibraryQueryService {
/** /**
* @param profileStats the stat group names of the profile ticket * @param profileStats the stat group names of the profile ticket
*/ */
@Public
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) { public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) {
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId); Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId, List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
@@ -3,6 +3,8 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository; import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -16,6 +18,7 @@ import java.time.ZoneId;
import java.util.List; import java.util.List;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class LibrarySyncService { public class LibrarySyncService {
@@ -1,11 +1,13 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
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.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame; import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
@@ -29,6 +31,7 @@ import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -47,6 +50,7 @@ public class LibrarySyncWorker {
private final SteamApiService steamApiService; private final SteamApiService steamApiService;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository gameCorrectionRepository;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
@@ -109,10 +113,13 @@ public class LibrarySyncWorker {
return; return;
} }
// setSourceName keeps them anyway; skipping saves an update and a flush per corrected game and sync.
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME,
games.stream().map(SteamOwnedGamesResponse.Game::appid).filter(Objects::nonNull).map(Integer::longValue).toList());
List<Long> played = new ArrayList<>(); List<Long> played = new ArrayList<>();
for (SteamOwnedGamesResponse.Game g : games) { for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue; if (g.appid() == null) continue;
upsertGame(g, syncStartedAt); upsertGame(g, syncStartedAt, correctedNames);
if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) { if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) {
played.add(g.appid().longValue()); played.add(g.appid().longValue());
} }
@@ -175,7 +182,7 @@ public class LibrarySyncWorker {
}).toList()); }).toList());
} }
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) { private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now, Set<Long> correctedNames) {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
SteamGame existing = steamGameRepository.findById(appid).orElse(null); SteamGame existing = steamGameRepository.findById(appid).orElse(null);
// An unvetted app can come without a name, and the name column is NOT NULL. // An unvetted app can come without a name, and the name column is NOT NULL.
@@ -183,7 +190,9 @@ public class LibrarySyncWorker {
if (existing == null) { if (existing == null) {
steamGameRepository.save(new SteamGame(appid, hasName ? g.name() : SteamGame.placeholderName(appid), g.imgIconUrl(), now)); steamGameRepository.save(new SteamGame(appid, hasName ? g.name() : SteamGame.placeholderName(appid), g.imgIconUrl(), now));
} else { } else {
if (hasName) existing.setName(g.name()); if (hasName && !g.name().equals(existing.getName()) && !correctedNames.contains(appid)) {
steamGameRepository.setSourceName(appid, g.name());
}
existing.setImgIconUrl(g.imgIconUrl()); existing.setImgIconUrl(g.imgIconUrl());
existing.setLastSeenAt(now); existing.setLastSeenAt(now);
} }
@@ -11,6 +11,8 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -32,6 +34,7 @@ import java.util.stream.Stream;
* to the Steam sync. * to the Steam sync.
*/ */
@Service @Service
@RequiresRole(Role.USER)
@Slf4j @Slf4j
public class ManualGameService { public class ManualGameService {
@@ -219,8 +222,8 @@ public class ManualGameService {
} }
steamGameRepository.save(created); steamGameRepository.save(created);
} else if (!GameKeys.isSteam(appid)) { } else if (!GameKeys.isSteam(appid)) {
existing.get().setName(game.name()); steamGameRepository.setSourceName(appid, game.name());
existing.get().setImageUrl(game.imageUrl()); steamGameRepository.setImageUrl(appid, game.imageUrl());
existing.get().setLastSeenAt(now); existing.get().setLastSeenAt(now);
} }
} }
@@ -30,6 +30,14 @@ final class SpeedrunMatch {
private SpeedrunMatch() { private SpeedrunMatch() {
} }
/**
* The weblink goes to the browser as a link; only speedrun.com's own pages may get there.
*/
static boolean isWeblink(String url) {
return url != null && url.startsWith(WEBLINK_PREFIX) && url.length() > WEBLINK_PREFIX.length()
&& url.length() <= MAX_WEBLINK_LENGTH;
}
/** /**
* @param years our release years of the game; empty when unknown * @param years our release years of the game; empty when unknown
* @param hits in speedrun.com's order * @param hits in speedrun.com's order
@@ -41,9 +49,7 @@ final class SpeedrunMatch {
if (ours.year() != null) ourYears.add(ours.year()); if (ours.year() != null) ourYears.add(ours.year());
return hits.stream() return hits.stream()
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH) .filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
// The weblink goes to the browser as a link; only speedrun.com's own pages may get there. .filter(hit -> isWeblink(hit.weblink()))
.filter(hit -> hit.weblink() != null && hit.weblink().startsWith(WEBLINK_PREFIX)
&& hit.weblink().length() <= MAX_WEBLINK_LENGTH)
.filter(hit -> { .filter(hit -> {
NameAndYear theirs = split(hit.name()); NameAndYear theirs = split(hit.name());
Integer year = theirs.year() != null ? theirs.year() : hit.year(); Integer year = theirs.year() != null ? theirs.year() : hit.year();
@@ -2,10 +2,13 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
public class SteamProfileService { public class SteamProfileService {
@@ -8,6 +8,8 @@ import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.BacklogSnapshotRepository; import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository; import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -33,10 +35,23 @@ public class UserStatsService {
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserStatsRepository userStatsRepository; private final UserStatsRepository userStatsRepository;
private final BacklogSnapshotRepository backlogSnapshotRepository; private final BacklogSnapshotRepository backlogSnapshotRepository;
private final MoodService moodService;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
/**
* The stats page of the user: {@link #getStats}, and for TASTE also a refresh of the listed games' moods in the
* background, not done for a profile visitor.
*/
@RequiresRole(Role.USER)
public UserStatsDto getOwnStats(long userId, Set<StatGroup> groups) {
if (groups.contains(StatGroup.TASTE)) {
moodService.refreshListedInBackground(userId);
}
return getStats(userId, groups);
}
/** /**
* Runs only the queries of the asked groups; the others stay null. * Runs only the queries of the asked groups; the others stay null.
*/ */
@@ -2,6 +2,7 @@ package core_service.modules.stats.service;
import core_service.modules.stats.api.dto.PublicStatsDto; import core_service.modules.stats.api.dto.PublicStatsDto;
import core_service.modules.stats.repo.StatsRepository; import core_service.modules.stats.repo.StatsRepository;
import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -10,6 +11,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
@Public
@RequiredArgsConstructor @RequiredArgsConstructor
public class PublicStatsService { public class PublicStatsService {
@@ -8,6 +8,8 @@ import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesR
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse; import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpClientErrorException;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry; import io.github.resilience4j.retry.annotation.Retry;
@@ -27,6 +29,7 @@ import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class SteamApiService { public class SteamApiService {
@@ -0,0 +1,16 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Callable only with Auth's service token (the /internal calls). See {@link RequiresRole}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Internal {
}
@@ -0,0 +1,16 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Callable without a login, e.g. the landing stats. See {@link RequiresRole}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Public {
}
@@ -0,0 +1,20 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The lowest active role that may call this service method, or every method of this class. A method marker
* replaces the class marker. Every service method a controller calls carries one of {@link RequiresRole},
* {@link Public} or {@link Internal} (ArchitectureTest). Checked only while an HTTP request is served: scheduled
* jobs and {@code @Async} work run without a user.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresRole {
Role value();
}
@@ -0,0 +1,26 @@
package core_service.shared.security;
/**
* A ladder: each role has every right of the roles before it. Auth owns the roles; the same enum lives there.
*/
public enum Role {
USER, AUDITOR, PLATFORM_ADMIN;
public boolean atLeast(Role other) {
return compareTo(other) >= 0;
}
/**
* USER for null or an unknown name, e.g. a token from before roles.
*/
public static Role parse(String name) {
if (name == null) {
return USER;
}
try {
return valueOf(name);
} catch (IllegalArgumentException _) {
return USER;
}
}
}
@@ -2,12 +2,20 @@ package core_service.shared.security;
import java.security.Principal; import java.security.Principal;
/**
* @param role the active role of this login ("view as"), set by Auth
*/
public record UserPrincipal( public record UserPrincipal(
String id, String id,
String email, String email,
Long steamId Long steamId,
Role role
) implements Principal { ) implements Principal {
public UserPrincipal(String id, String email, Long steamId) {
this(id, email, steamId, Role.USER);
}
@Override @Override
public String getName() { public String getName() {
return id; return id;
@@ -0,0 +1,13 @@
-- An auditor's correction of shared game data. The value is also written into the cached row (steam_game,
-- game_speedrun, game_mood), so reads need no join; the refresh writers skip a corrected field. value and
-- source_value are JSON (a string, a string list, a boolean or null); source_value is what the source had at the
-- first correction, written back by "back to source".
CREATE TABLE game_correction (
appid BIGINT NOT NULL,
field VARCHAR(16) NOT NULL,
value TEXT,
source_value TEXT,
corrected_by BIGINT NOT NULL,
corrected_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (appid, field)
);
@@ -100,4 +100,17 @@ class ArchitectureTest extends AbstractArchitectureTest {
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class) .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class)
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class) .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class)
.because("Classes in 'api' package named *Controller should be Spring REST controllers."); .because("Classes in 'api' package named *Controller should be Spring REST controllers.");
// ===================================================================================
// 5. ROLE MARKERS (see RoleAnnotationRules)
// ===================================================================================
@ArchTest
static final ArchRule controllers_call_only_marked_service_methods = RoleAnnotationRules.controllers_call_only_marked_service_methods;
@ArchTest
static final ArchRule one_role_marker_per_element = RoleAnnotationRules.one_role_marker_per_element;
@ArchTest
static final ArchRule services_never_call_a_method_that_needs_more = RoleAnnotationRules.services_never_call_a_method_that_needs_more;
} }
@@ -0,0 +1,198 @@
package core_service.architecture;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaCodeUnit;
import com.tngtech.archunit.core.domain.JavaCodeUnitAccess;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.domain.properties.HasAnnotations;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.scheduling.annotation.Async;
import java.lang.annotation.Annotation;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
/**
* The rules for {@link RequiresRole}, {@link Public} and {@link Internal}. Auth has the same rules without
* {@code Internal}.
*/
final class RoleAnnotationRules {
private static final List<Class<? extends Annotation>> MARKERS = List.of(RequiresRole.class, Public.class,
Internal.class);
private static final String APP_PACKAGE = "core_service.";
private RoleAnnotationRules() {
}
/**
* What a marked method needs from its caller. {@code role} is set only for {@link RequiresRole}.
*/
private record Need(Class<? extends Annotation> marker, Role role) {
boolean isMetBy(Need caller) {
if (marker == Public.class) {
return true;
}
if (marker == Internal.class) {
return caller.marker == Internal.class;
}
return caller.marker == RequiresRole.class && caller.role.atLeast(role);
}
@Override
public String toString() {
return marker == RequiresRole.class ? "@RequiresRole(" + role + ")" : "@" + marker.getSimpleName();
}
}
private static long markerCount(HasAnnotations<?> element) {
return MARKERS.stream().filter(element::isAnnotatedWith).count();
}
private static Optional<Need> need(HasAnnotations<?> element) {
for (Class<? extends Annotation> marker : MARKERS) {
if (element.isAnnotatedWith(marker)) {
Role role = marker == RequiresRole.class
? element.getAnnotationOfType(RequiresRole.class).value() : null;
return Optional.of(new Need(marker, role));
}
}
return Optional.empty();
}
private static Optional<Need> effectiveNeed(JavaMethod method) {
Optional<Need> own = need(method);
return own.isPresent() ? own : need(method.getOwner());
}
private record MethodAccess(JavaCodeUnitAccess<?> access, JavaMethod target) {
@Override
public String toString() {
return target.getOwner().getSimpleName() + "." + target.getName() + " called in "
+ access.getSourceCodeLocation();
}
}
/**
* Calls and method references ({@code service::method}) to non-static methods: a proxy never sees a static call,
* so a marker there would promise a check that does not run.
*/
private static List<MethodAccess> methodAccesses(JavaCodeUnit from) {
return Stream.<JavaCodeUnitAccess<?>>concat(from.getMethodCallsFromSelf().stream(),
from.getMethodReferencesFromSelf().stream())
.flatMap(access -> access.getTarget().resolveMember().stream()
.filter(JavaMethod.class::isInstance).map(JavaMethod.class::cast)
.filter(method -> !method.getModifiers().contains(JavaModifier.STATIC))
.map(method -> new MethodAccess(access, method)))
.toList();
}
private static boolean isService(JavaClass javaClass) {
return javaClass.getSimpleName().endsWith("Service") && javaClass.getPackageName().contains(".service");
}
private static boolean isAsync(JavaMethod method) {
return method.isAnnotatedWith(Async.class) || method.getOwner().isAnnotatedWith(Async.class);
}
private static final DescribedPredicate<JavaClass> SERVICE_CLASSES =
DescribedPredicate.describe("are services", RoleAnnotationRules::isService);
/**
* Every service method a controller calls is marked, and is not {@code @Async}: there the check would never run.
*/
static final ArchRule controllers_call_only_marked_service_methods = classes()
.that().resideInAPackage("..api..")
.should(new ArchCondition<>("call only service methods that carry a role marker and are not @Async") {
@Override
public void check(JavaClass controller, ConditionEvents events) {
for (JavaCodeUnit codeUnit : controller.getCodeUnits()) {
for (MethodAccess access : methodAccesses(codeUnit)) {
if (!isService(access.target().getOwner())) {
continue;
}
if (effectiveNeed(access.target()).isEmpty()) {
events.add(SimpleConditionEvent.violated(access.access(), "no role marker on " + access));
}
if (isAsync(access.target())) {
events.add(SimpleConditionEvent.violated(access.access(), "@Async method " + access));
}
}
}
}
})
.because("a new endpoint must not forget the role check");
static final ArchRule one_role_marker_per_element = classes()
.should(new ArchCondition<>("carry at most one role marker on the class and on each method") {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
if (markerCount(javaClass) > 1) {
events.add(SimpleConditionEvent.violated(javaClass, javaClass.getName() + " has two role markers"));
}
for (JavaMethod method : javaClass.getMethods()) {
if (markerCount(method) > 1) {
events.add(SimpleConditionEvent.violated(method, method.getFullName() + " has two role markers"));
}
}
}
})
.because("the marker must say one thing");
/**
* Starts at every method the proxy checks (not private, not static) and follows every call that is not checked
* again: self calls and calls of unmarked methods. Calls through an interface (a port) end the walk: the
* implementation is not known here.
*/
static final ArchRule services_never_call_a_method_that_needs_more = classes()
.that(SERVICE_CLASSES)
.should(new ArchCondition<>("never call a service method that needs more than the caller") {
@Override
public void check(JavaClass service, ConditionEvents events) {
for (JavaMethod entry : service.getMethods()) {
Optional<Need> caller = effectiveNeed(entry);
if (caller.isEmpty() || entry.getModifiers().contains(JavaModifier.PRIVATE)
|| entry.getModifiers().contains(JavaModifier.STATIC)) {
continue;
}
Set<JavaCodeUnit> seen = new HashSet<>(List.of(entry));
Deque<JavaCodeUnit> toVisit = new ArrayDeque<>(seen);
while (!toVisit.isEmpty()) {
for (MethodAccess access : methodAccesses(toVisit.pop())) {
JavaMethod target = access.target();
Optional<Need> callee = target.getOwner().equals(service) ? Optional.empty()
: effectiveNeed(target);
if (callee.isEmpty()) {
if (target.getOwner().getPackageName().startsWith(APP_PACKAGE) && seen.add(target)) {
toVisit.push(target);
}
} else if (!callee.get().isMetBy(caller.get())) {
events.add(SimpleConditionEvent.violated(access.access(), entry.getName() + " ("
+ caller.get() + ") calls " + access + ", which needs " + callee.get()));
}
}
}
}
}
})
.because("the inner check would refuse a caller the outer check let in");
}
@@ -0,0 +1,113 @@
package core_service.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.EvaluationResult;
import core_service.architecture.fixture.api.BadController;
import core_service.architecture.fixture.api.GoodController;
import core_service.architecture.fixture.api.MethodReferenceController;
import core_service.architecture.fixture.service.ForwardingService;
import core_service.architecture.fixture.service.InternalCallerService;
import core_service.architecture.fixture.service.InternalMarkedService;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.PublicCallerService;
import core_service.architecture.fixture.service.PublicViaUnmarkedCallerService;
import core_service.architecture.fixture.service.PublicHelperCallerService;
import core_service.architecture.fixture.service.PublicMethodReferenceCallerService;
import core_service.architecture.fixture.service.TwoMarkersService;
import core_service.architecture.fixture.service.UnmarkedService;
import core_service.architecture.fixture.service.UserCallerService;
import core_service.shared.test.AbstractArchitectureTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RoleAnnotationRulesTest extends AbstractArchitectureTest {
private static List<String> violations(ArchRule rule, Class<?>... classes) {
JavaClasses imported = new ClassFileImporter().importClasses(classes);
EvaluationResult result = rule.evaluate(imported);
return result.getFailureReport().getDetails();
}
@Test
void controllerRule_PassesMarkedMethodsTheMethodMarkerAndStaticHelpers() {
assertThat(violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
GoodController.class, MarkedService.class, UnmarkedService.class)).isEmpty();
}
@Test
void controllerRule_FindsAnUnmarkedAndAnAsyncMethod() {
List<String> found = violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
BadController.class, MarkedService.class, UnmarkedService.class);
assertThat(found).hasSize(2);
assertThat(found).anyMatch(line -> line.contains("no role marker on UnmarkedService.bare"));
assertThat(found).anyMatch(line -> line.contains("@Async method MarkedService.later"));
}
@Test
void markerRule_FindsTwoMarkersOnOneMethod() {
assertThat(violations(RoleAnnotationRules.one_role_marker_per_element, TwoMarkersService.class))
.singleElement().asString().contains("both");
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethod() {
List<String> found = violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicCallerService.class, MarkedService.class);
assertThat(found).singleElement().asString()
.contains("callsUser (@Public)", "MarkedService.plain", "needs @RequiresRole(USER)");
}
@Test
void escalationRule_FindsAUserMethodCallingAnAuditorMethod() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
UserCallerService.class, MarkedService.class))
.singleElement().asString().contains("needs @RequiresRole(AUDITOR)");
}
@Test
void controllerRule_FindsAnUnmarkedMethodCalledByMethodReference() {
assertThat(violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
MethodReferenceController.class, UnmarkedService.class))
.anyMatch(line -> line.contains("no role marker on UnmarkedService.bare"));
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethodThroughAPrivateHelper() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicHelperCallerService.class, MarkedService.class))
.anyMatch(line -> line.contains("MarkedService.plain") && line.contains("needs @RequiresRole(USER)"));
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethodByMethodReference() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicMethodReferenceCallerService.class, MarkedService.class))
.anyMatch(line -> line.contains("MarkedService.plain") && line.contains("needs @RequiresRole(USER)"));
}
@Test
void escalationRule_FindsAPublicMethodReachingAUserMethodThroughAnUnmarkedService() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicViaUnmarkedCallerService.class, ForwardingService.class, MarkedService.class))
.singleElement().asString()
.contains("callsThroughAnUnmarkedService (@Public)", "MarkedService.plain", "needs @RequiresRole(USER)");
}
@Test
void escalationRule_KeepsInternalAndRolesApart() {
List<String> found = violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
InternalCallerService.class, InternalMarkedService.class, MarkedService.class);
assertThat(found).hasSize(2);
assertThat(found).anyMatch(line -> line.contains("callsUser (@Internal)") && line.contains("needs @RequiresRole(USER)"));
assertThat(found).anyMatch(line -> line.contains("adminCallsInternal (@RequiresRole(PLATFORM_ADMIN))")
&& line.contains("needs @Internal"));
}
}
@@ -0,0 +1,15 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.UnmarkedService;
public class BadController {
private final UnmarkedService unmarkedService = new UnmarkedService();
private final MarkedService markedService = new MarkedService();
public void handle() {
unmarkedService.bare();
markedService.later();
}
}
@@ -0,0 +1,15 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.UnmarkedService;
public class GoodController {
private final MarkedService markedService = new MarkedService();
public void handle() {
markedService.plain();
markedService.open();
UnmarkedService.helper();
}
}
@@ -0,0 +1,13 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.UnmarkedService;
public class MethodReferenceController {
private final UnmarkedService unmarkedService = new UnmarkedService();
public void handle() {
Runnable call = unmarkedService::bare;
call.run();
}
}
@@ -0,0 +1,10 @@
package core_service.architecture.fixture.service;
public class ForwardingService {
private final MarkedService markedService = new MarkedService();
public void forward() {
markedService.plain();
}
}
@@ -0,0 +1,25 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@Internal
public class InternalCallerService {
private final MarkedService markedService = new MarkedService();
private final InternalMarkedService internalMarkedService = new InternalMarkedService();
public void callsInternal() {
internalMarkedService.call();
}
public void callsUser() {
markedService.plain();
}
@RequiresRole(Role.PLATFORM_ADMIN)
public void adminCallsInternal() {
internalMarkedService.call();
}
}
@@ -0,0 +1,10 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
@Internal
public class InternalMarkedService {
public void call() {
}
}
@@ -0,0 +1,25 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.scheduling.annotation.Async;
@RequiresRole(Role.USER)
public class MarkedService {
public void plain() {
}
@Public
public void open() {
}
@RequiresRole(Role.AUDITOR)
public void audit() {
}
@Async
public void later() {
}
}
@@ -0,0 +1,17 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
@Public
public class PublicCallerService {
private final MarkedService markedService = new MarkedService();
public void callsOpen() {
markedService.open();
}
public void callsUser() {
markedService.plain();
}
}
@@ -0,0 +1,20 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class PublicHelperCallerService {
private final MarkedService markedService = new MarkedService();
@Public
public void open() {
helper();
}
private void helper() {
markedService.plain();
}
}
@@ -0,0 +1,17 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class PublicMethodReferenceCallerService {
private final MarkedService markedService = new MarkedService();
@Public
public void open() {
Runnable call = markedService::plain;
call.run();
}
}
@@ -0,0 +1,13 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
@Public
public class PublicViaUnmarkedCallerService {
private final ForwardingService forwardingService = new ForwardingService();
public void callsThroughAnUnmarkedService() {
forwardingService.forward();
}
}
@@ -0,0 +1,12 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
public class TwoMarkersService {
@Public
@Internal
public void both() {
}
}
@@ -0,0 +1,11 @@
package core_service.architecture.fixture.service;
public class UnmarkedService {
public void bare() {
}
public static boolean helper() {
return true;
}
}
@@ -0,0 +1,14 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class UserCallerService {
private final MarkedService markedService = new MarkedService();
public void callsAuditor() {
markedService.audit();
}
}
@@ -1,6 +1,7 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicket;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
@@ -166,4 +167,33 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ServiceTokenIsNoLogin() { void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L))); assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
} }
private String signTokenWithRole(String role) {
return Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.claim("role", role)
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
}
@Test
void parseLoginToken_ReadsTheActiveRole() {
assertEquals(Role.AUDITOR, jwtService.parseLoginToken(signTokenWithRole("AUDITOR")).orElseThrow().role());
}
@Test
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", "test@example.com", null)).orElseThrow().role());
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
}
@Test
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
String token = Jwts.builder().subject("test@example.com").claim("userId", "0815").claim("role", List.of("AUDITOR"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
}
} }
@@ -20,6 +20,7 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -91,6 +92,23 @@ class PublicEndpointsTest {
.andExpect(jsonPath("$.code").value("INVALID_TICKET")); .andExpect(jsonPath("$.code").value("INVALID_TICKET"));
} }
// Without a login: a class role marker on the way would answer 401.
@Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
.claim("profileStats", List.of("TOP_GAMES", "OVERVIEW")).expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats.topGames").exists());
}
@Test
void landingStatsArePublic() throws Exception {
mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk());
}
@Test @Test
void versionIsPublic() throws Exception { void versionIsPublic() throws Exception {
mockMvc.perform(get("/api/version")) mockMvc.perform(get("/api/version"))
@@ -0,0 +1,119 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractUnitTest;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
class RoleAuthorizationManagerTest extends AbstractUnitTest {
private final RoleAuthorizationManager manager = new RoleAuthorizationManager();
@RequiresRole(Role.USER)
static class UserMarked {
public void plain() {
}
@RequiresRole(Role.AUDITOR)
public void audit() {
}
@Public
public void open() {
}
}
@Internal
static class InternalMarked {
public void call() {
}
}
@AfterEach
void clearRequest() {
RequestContextHolder.resetRequestAttributes();
}
private static void inRequest() {
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest()));
}
private static Authentication login(Role role) {
return new UsernamePasswordAuthenticationToken("42", null, AccessTokens.authorities(role));
}
private static Authentication anonymous() {
return new AnonymousAuthenticationToken("key", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
}
private static Authentication service() {
return new UsernamePasswordAuthenticationToken("tbp-auth", null,
List.of(new SimpleGrantedAuthority(ServiceTokenFilter.SERVICE_AUTHORITY)));
}
private boolean granted(Authentication authentication, Object target, String method) throws Exception {
MethodInvocation invocation = mock(MethodInvocation.class);
// Lenient: without a request the manager does not look at the invocation.
lenient().when(invocation.getThis()).thenReturn(target);
lenient().when(invocation.getMethod()).thenReturn(target.getClass().getMethod(method));
return manager.authorize(() -> authentication, invocation).isGranted();
}
@Test
void classMarker_CoversTheMethodsWithoutOwnMarker() throws Exception {
inRequest();
assertTrue(granted(login(Role.USER), new UserMarked(), "plain"));
assertFalse(granted(anonymous(), new UserMarked(), "plain"));
}
@Test
void methodMarker_ReplacesTheClassMarker() throws Exception {
inRequest();
assertFalse(granted(login(Role.USER), new UserMarked(), "audit"));
assertTrue(granted(anonymous(), new UserMarked(), "open"));
}
@Test
void higherRoles_PassLowerChecks() throws Exception {
inRequest();
assertTrue(granted(login(Role.AUDITOR), new UserMarked(), "audit"));
assertTrue(granted(login(Role.PLATFORM_ADMIN), new UserMarked(), "audit"));
assertTrue(granted(login(Role.PLATFORM_ADMIN), new UserMarked(), "plain"));
}
@Test
void internal_NeedsTheServiceToken() throws Exception {
inRequest();
assertTrue(granted(service(), new InternalMarked(), "call"));
assertFalse(granted(login(Role.PLATFORM_ADMIN), new InternalMarked(), "call"));
assertFalse(granted(service(), new UserMarked(), "plain"));
}
@Test
void withoutARequest_EverythingRuns() throws Exception {
assertTrue(granted(null, new UserMarked(), "audit"));
assertTrue(granted(null, new InternalMarked(), "call"));
}
}
@@ -0,0 +1,137 @@
package core_service.configuration.security;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.stereotype.Service;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The role markers through the real (non-dev) filter chain, on a probe that only this test sees.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
@Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class})
class RoleCheckFlowTest extends AbstractIntegrationTest {
@Service
@RequiresRole(Role.USER)
static class ProbeService {
public String user() {
return "user";
}
@RequiresRole(Role.AUDITOR)
public String auditor() {
return "auditor";
}
@Public
public String open() {
return "open";
}
}
@RestController
static class ProbeController {
private final ProbeService probeService;
ProbeController(ProbeService probeService) {
this.probeService = probeService;
}
@GetMapping("/api/role-probe/user")
String user() {
return probeService.user();
}
@GetMapping("/api/role-probe/auditor")
String auditor() {
return probeService.auditor();
}
// Authenticated URL: the marker opens nothing the URL rules close.
@GetMapping("/api/role-probe/open")
String open() {
return probeService.open();
}
// Public URL: only the marker stops an anonymous caller.
@GetMapping("/api/public/role-probe/user")
String publicUser() {
return probeService.user();
}
@GetMapping("/api/public/role-probe/open")
String publicOpen() {
return probeService.open();
}
}
@Autowired
private MockMvc mockMvc;
@Autowired
private ProbeService probeService;
private static String token(Role role) {
return Jwts.builder()
.subject("probe@example.com")
.claim("userId", "4711")
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private ResultActions call(String path, Role role) throws Exception {
MockHttpServletRequestBuilder request = get("/api/role-probe/" + path);
if (role != null) {
request.cookie(new Cookie(AccessTokens.ACCESS_COOKIE, token(role)));
}
return mockMvc.perform(request);
}
@Test
void user_PassesUserAndIsRefusedAuditor() throws Exception {
call("user", Role.USER).andExpect(status().isOk());
call("open", Role.USER).andExpect(status().isOk());
call("auditor", Role.USER).andExpect(status().isForbidden());
}
@Test
void higherRoles_PassTheLowerChecks() throws Exception {
call("auditor", Role.AUDITOR).andExpect(status().isOk());
call("auditor", Role.PLATFORM_ADMIN).andExpect(status().isOk());
call("user", Role.PLATFORM_ADMIN).andExpect(status().isOk());
}
@Test
void noLogin_OnAPublicUrl_IsStoppedByTheMarker() throws Exception {
mockMvc.perform(get("/api/public/role-probe/user")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/public/role-probe/open")).andExpect(status().isOk());
}
@Test
void outsideARequest_TheServiceRuns() {
assertEquals("auditor", probeService.auditor());
}
}
@@ -0,0 +1,191 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.empty;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Corrections through the real (non-dev) filter chain, so the role in the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GameCorrectionFlowTest extends AbstractIntegrationTest {
private static final long AUDITOR_ID = 4711L;
private static final long GAME = 620L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@BeforeEach
void seed() {
gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll();
gameMoodsRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
UserGame owned = new UserGame(AUDITOR_ID, GAME, 60, Instant.now(), Instant.now());
// Hidden: the details make no calls to Steam, IGDB or speedrun.com.
owned.setHiddenAt(Instant.now());
userGameRepository.save(owned);
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, false, Instant.now());
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
}
private static String token(Role role) {
return Jwts.builder()
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/corrections/" + field)
.header("Authorization", "Bearer " + token(role)).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"value\":" + valueJson + "}"));
}
private ResultActions reset(String field) throws Exception {
return mockMvc.perform(delete("/api/library/games/" + GAME + "/corrections/" + field)
.header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private ResultActions details() throws Exception {
return mockMvc.perform(get("/api/library/games/" + GAME).header("Authorization", "Bearer " + token(Role.USER)));
}
@Test
void name_IsCorrectedForEveryoneAndGoesBackToTheSource() throws Exception {
correct(Role.AUDITOR, GAME, "name", "\" Portal Two \"").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.name").value("Portal Two"))
.andExpect(jsonPath("$.corrections[0].field").value("NAME"));
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME).getFirst().getCorrectedBy()).isEqualTo(AUDITOR_ID);
reset("name").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.name").value("Portal 2"))
.andExpect(jsonPath("$.corrections", empty()));
}
@Test
void moodsAndSpeedrun_SurviveTheNextRefresh() throws Exception {
correct(Role.AUDITOR, GAME, "moods", "[\"COOP\", \"CHILL\"]").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "speedrun", "null").andExpect(status().isNoContent());
gameMoodsRepository.upsert(GAME, new String[]{"ACTION"}, new String[]{"Shooter"}, true, Instant.now());
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
details().andExpect(jsonPath("$.moods", contains("CHILL", "COOP")))
.andExpect(jsonPath("$.genres", contains("Shooter")))
.andExpect(jsonPath("$.speedrunUrl").doesNotExist());
}
@Test
void reset_WritesTheSourceBackAndAsksTheSourceAgain() throws Exception {
correct(Role.AUDITOR, GAME, "moods", "[\"COOP\"]").andExpect(status().isNoContent());
reset("moods").andExpect(status().isNoContent());
GameMoods stored = gameMoodsRepository.findById(GAME).orElseThrow();
assertThat(stored.getMoods()).containsExactly("STORY");
assertThat(stored.getCheckedAt()).isEqualTo(Instant.EPOCH);
}
@Test
void genresAndEndless_AreStored() throws Exception {
correct(Role.AUDITOR, GAME, "genres", "[\"Puzzle\", \"Platform\", \"Puzzle\"]").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "endless", "true").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.genres", contains("Platform", "Puzzle")))
.andExpect(jsonPath("$.endless").value(true));
}
@Test
void roles_OnlyAuditorsAndAdminsCorrect() throws Exception {
correct(Role.USER, GAME, "name", "\"Hacked\"").andExpect(status().isForbidden());
correct(Role.USER, GAME, "rating", "5").andExpect(status().isForbidden());
mockMvc.perform(delete("/api/library/games/" + GAME + "/corrections/name")
.header("Authorization", "Bearer " + token(Role.USER)).header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isForbidden());
mockMvc.perform(put("/api/library/games/" + GAME + "/corrections/name").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"value\":\"x\"}"))
.andExpect(status().isUnauthorized());
correct(Role.PLATFORM_ADMIN, GAME, "name", "\"Portal Two\"").andExpect(status().isNoContent());
}
@Test
void reset_RestoresNullSourcesAndAsksSpeedrunComAgain() throws Exception {
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, null, Instant.now());
correct(Role.AUDITOR, GAME, "image", "\"https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg\"")
.andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "endless", "true").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "speedrun", "\"https://www.speedrun.com/portal\"").andExpect(status().isNoContent());
reset("image").andExpect(status().isNoContent());
reset("endless").andExpect(status().isNoContent());
reset("speedrun").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.imageUrl").doesNotExist())
.andExpect(jsonPath("$.endless").doesNotExist())
.andExpect(jsonPath("$.speedrunUrl").value("https://www.speedrun.com/portal_2"))
.andExpect(jsonPath("$.corrections", empty()));
assertThat(gameSpeedrunRepository.findById(GAME).orElseThrow().getCheckedAt()).isEqualTo(Instant.EPOCH);
}
@Test
void badInput_IsRefused() throws Exception {
correct(Role.AUDITOR, 999_999L, "name", "\"x\"").andExpect(status().isNotFound());
correct(Role.AUDITOR, GAME, "rating", "5").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_CORRECTION"));
correct(Role.AUDITOR, GAME, "image", "\"https://evil.example.com/a.jpg\"").andExpect(status().isBadRequest());
correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").andExpect(status().isBadRequest());
correct(Role.AUDITOR, GAME, "name", "\" \"").andExpect(status().isBadRequest());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.GameMoods;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -67,4 +68,20 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30)))) assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L)); .isEqualTo(Set.of(620L));
} }
@Test
void upsert_KeepsACorrectedColumnAndRefreshesTheOthers() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL"}, new String[]{"Puzzle"}, false, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'MOODS', '[\"COOP\"]', '[\"CHILL\"]', 7, now())").executeUpdate();
gameMoodsRepository.upsert(620L, new String[]{"ACTION"}, new String[]{"Shooter"}, true, Instant.now());
entityManager.clear();
GameMoods stored = gameMoodsRepository.findById(620L).orElseThrow();
assertThat(stored.getMoods()).containsExactly("CHILL");
assertThat(stored.getGenres()).containsExactly("Shooter");
assertThat(stored.getEndless()).isTrue();
assertThat(stored.getCheckedAt()).isAfter(Instant.EPOCH);
}
} }
@@ -48,4 +48,20 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
assertThat(gameSpeedrunRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30)))) assertThat(gameSpeedrunRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L)); .isEqualTo(Set.of(620L));
} }
@Test
void upsert_KeepsACorrectedLink() {
gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'SPEEDRUN', 'null', '\"https://www.speedrun.com/portal\"', 7, now())")
.executeUpdate();
gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
entityManager.clear();
GameSpeedrun row = gameSpeedrunRepository.findById(620L).orElseThrow();
assertThat(row.getWeblink()).isNull();
assertThat(row.getSpeedrunGameId()).isNull();
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
}
} }
@@ -0,0 +1,58 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.SteamGame;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class SteamGameRepositoryTest extends AbstractPersistenceTest {
private static final String REPAIRED = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/abc/header.jpg";
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private EntityManager entityManager;
private String storedImage(long appid) {
entityManager.clear();
return steamGameRepository.findById(appid).orElseThrow().getImageUrl();
}
@Test
void setImageUrl_KeepsAnAuditorsImage() {
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(400L, "Portal", null, Instant.now()));
entityManager.flush();
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'IMAGE', 'null', 'null', 7, now())").executeUpdate();
steamGameRepository.setImageUrl(620L, REPAIRED);
steamGameRepository.setImageUrl(400L, REPAIRED);
assertThat(storedImage(620L)).isNull();
assertThat(storedImage(400L)).isEqualTo(REPAIRED);
}
@Test
void setSourceName_KeepsAnAuditorsName() {
steamGameRepository.save(new SteamGame(620L, "Portal Two", null, Instant.now()));
steamGameRepository.save(new SteamGame(400L, "Portal", null, Instant.now()));
entityManager.flush();
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'NAME', '\"Portal Two\"', '\"Portal 2\"', 7, now())").executeUpdate();
steamGameRepository.setSourceName(620L, "Portal 2");
steamGameRepository.setSourceName(400L, "Portal: Still Alive");
entityManager.clear();
assertThat(steamGameRepository.findById(620L).orElseThrow().getName()).isEqualTo("Portal Two");
assertThat(steamGameRepository.findById(400L).orElseThrow().getName()).isEqualTo("Portal: Still Alive");
}
}
@@ -0,0 +1,105 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest {
private static final long GAME = 620L;
private static final String IMAGE = "https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg";
@Autowired
private GameCorrectionService gameCorrectionService;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
@AfterEach
void clean() {
jdbcTemplate.update("delete from game_correction where appid = ?", GAME);
if (steamGameRepository.findById(GAME).isEmpty()) {
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
}
jdbcTemplate.update("update steam_game set name = 'Portal 2', image_url = null where appid = ?", GAME);
}
@Test
void twoAuditorsCorrectTheSameFieldForTheFirstTime_BothSucceed() {
CountDownLatch firstCorrected = new CountDownLatch(1);
// The first commits a moment after the second has started, so both see no row before either commits.
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
gameCorrectionService.correct(GAME, "name", "Portal Two", 1L);
firstCorrected.countDown();
pause();
}));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstCorrected);
gameCorrectionService.correct(GAME, "name", "Portal II", 2L);
});
assertDoesNotThrow(() -> CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS));
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME)).hasSize(1);
}
@Test
void aCorrectionCommittedDuringASync_SurvivesTheSync() {
new TransactionTemplate(transactionManager).executeWithoutResult(tx -> {
// What LibrarySyncWorker.upsertGame does for every owned game.
SteamGame existing = steamGameRepository.findById(GAME).orElseThrow();
existing.setImgIconUrl("icon");
existing.setLastSeenAt(Instant.now());
assertDoesNotThrow(() -> CompletableFuture.runAsync(() ->
gameCorrectionService.correct(GAME, "image", IMAGE, 1L)).get(10, TimeUnit.SECONDS));
});
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME)).extracting("field").containsExactly(CorrectionField.IMAGE);
assertThat(steamGameRepository.findById(GAME).orElseThrow().getImageUrl()).isEqualTo(IMAGE);
}
private static void pause() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,63 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameCorrectionServiceTest extends AbstractUnitTest {
private static void refused(CorrectionField field, Object value) {
assertThatThrownBy(() -> GameCorrectionService.validate(field, value))
.as(field + " " + value).isInstanceOf(InvalidCorrectionException.class);
}
@Test
void image_OnlyHttpsOnTheAllowedHosts() {
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/header.jpg")).isNotNull();
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
"https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg")).isNotNull();
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE, null)).isNull();
refused(CorrectionField.IMAGE, "http://images.igdb.com/a.jpg");
refused(CorrectionField.IMAGE, "https://steamstatic.com.evil.example/a.jpg");
refused(CorrectionField.IMAGE, "not a url");
}
@Test
void speedrun_OnlyAPageOnSpeedrunCom() {
assertThat(GameCorrectionService.validate(CorrectionField.SPEEDRUN, "https://www.speedrun.com/portal_2"))
.isEqualTo("https://www.speedrun.com/portal_2");
assertThat(GameCorrectionService.validate(CorrectionField.SPEEDRUN, null)).isNull();
refused(CorrectionField.SPEEDRUN, "https://www.speedrun.com/");
refused(CorrectionField.SPEEDRUN, "https://speedrun.com.evil.example/portal");
refused(CorrectionField.SPEEDRUN, 42);
}
@Test
void lists_AreSortedWithoutDuplicatesAndLimited() {
assertThat(GameCorrectionService.validate(CorrectionField.MOODS, List.of("COOP", "CHILL", "COOP")))
.isEqualTo(List.of("CHILL", "COOP"));
assertThat(GameCorrectionService.validate(CorrectionField.MOODS, List.of())).isEqualTo(List.of());
refused(CorrectionField.MOODS, List.of("SPEEDRUN"));
refused(CorrectionField.MOODS, List.of("chill"));
refused(CorrectionField.MOODS, "CHILL");
refused(CorrectionField.GENRES, Collections.nCopies(11, "x").stream().map(g -> g + Math.random()).toList());
refused(CorrectionField.GENRES, List.of(" "));
refused(CorrectionField.GENRES, List.of(1));
}
@Test
void nameAndEndless() {
assertThat(GameCorrectionService.validate(CorrectionField.NAME, " Portal 2 ")).isEqualTo("Portal 2");
refused(CorrectionField.NAME, null);
refused(CorrectionField.NAME, "x".repeat(513));
assertThat(GameCorrectionService.validate(CorrectionField.ENDLESS, null)).isNull();
refused(CorrectionField.ENDLESS, "true");
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage; import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.ImageKind; import core_service.modules.library.domain.ImageKind;
@@ -8,6 +9,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository; import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.modules.steam_adapter.service.SteamApiService;
@@ -22,6 +24,7 @@ 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;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
@@ -48,6 +51,8 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Mock @Mock
private SteamGameRepository steamGameRepository; private SteamGameRepository steamGameRepository;
@Mock @Mock
private GameCorrectionRepository gameCorrectionRepository;
@Mock
private GameAchievementSchemaRepository schemaRepository; private GameAchievementSchemaRepository schemaRepository;
@Mock @Mock
private GameAchievementSchemaMetaRepository schemaMetaRepository; private GameAchievementSchemaMetaRepository schemaMetaRepository;
@@ -62,7 +67,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, schemaRepository, service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, gameCorrectionRepository, schemaRepository,
schemaMetaRepository, achievementSyncService, steamApiService, steamCdnService, BASE, 200, 3, 30); schemaMetaRepository, achievementSyncService, steamApiService, steamCdnService, BASE, 200, 3, 30);
} }
@@ -281,4 +286,17 @@ class ImageRepairServiceTest extends AbstractUnitTest {
// Clear the flag for the next test on this thread. // Clear the flag for the next test on this thread.
org.assertj.core.api.Assertions.assertThat(Thread.interrupted()).isTrue(); org.assertj.core.api.Assertions.assertThat(Thread.interrupted()).isTrue();
} }
@Test
void repairNightly_ABrokenCorrectedCoverIsKeptAndCountsAnAttempt() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.IMAGE), any())).thenReturn(Set.of(620L));
when(steamCdnService.isAvailable(FIXED_620)).thenReturn(false);
service.repairNightly();
verify(brokenImageRepository, never()).deleteReportedUpTo(anyLong(), anyString(), any());
verifyNoInteractions(steamApiService);
verify(steamGameRepository, never()).setImageUrl(anyLong(), anyString());
}
} }
@@ -1,5 +1,6 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -7,6 +8,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame; import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
@@ -32,6 +34,7 @@ import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
@@ -40,6 +43,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
@@ -91,6 +95,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private SteamGameRepository steamGameRepository; private SteamGameRepository steamGameRepository;
@Mock
private GameCorrectionRepository gameCorrectionRepository;
@Mock @Mock
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@@ -541,4 +548,29 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertThat(stored.getName()).isEqualTo("El Mucho"); assertThat(stored.getName()).isEqualTo("El Mucho");
} }
@Test
void runSync_NewName_GoesThroughTheGuardedUpdate() {
SteamGame stored = new SteamGame(220L, "Half-Life 2: Corrected", null, Instant.EPOCH);
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(stored));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
// The update keeps an auditor's name (SteamGameRepositoryTest); the entity itself is not renamed.
verify(steamGameRepository).setSourceName(220L, "Half-Life 2");
assertThat(stored.getName()).isEqualTo("Half-Life 2: Corrected");
assertThat(stored.getImgIconUrl()).isEqualTo("icon");
}
@Test
void runSync_CorrectedName_SendsNoUpdate() {
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(new SteamGame(220L, "HL2 (corrected)", null, Instant.EPOCH)));
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.NAME), any())).thenReturn(Set.of(220L));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
verify(steamGameRepository, never()).setSourceName(anyLong(), anyString());
}
} }
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
@@ -12,6 +13,7 @@ import org.mockito.Mock;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -84,6 +86,22 @@ class ManualGameServiceTest extends AbstractUnitTest {
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L)); assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
} }
@Test
void add_KnownGameOffSteam_UpdatesNameAndImageThroughTheGuardedUpdates() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
SteamGame stored = new SteamGame(-119388L, "Zelda (corrected)", null, Instant.EPOCH);
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.of(stored));
service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
// Both updates keep an auditor's value (SteamGameRepositoryTest); the entity is not changed.
verify(steamGameRepository).setSourceName(-119388L, ZELDA.name());
verify(steamGameRepository).setImageUrl(-119388L, ZELDA.imageUrl());
assertThat(stored.getName()).isEqualTo("Zelda (corrected)");
}
private static IgdbApiService.Game game(long igdbId, String name) { private static IgdbApiService.Game game(long igdbId, String name) {
return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null); return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null);
} }
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -29,6 +30,9 @@ import static org.assertj.core.api.Assertions.assertThat;
@Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class}) @Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class})
class UserStatsServiceTrendTest extends AbstractPersistenceTest { class UserStatsServiceTrendTest extends AbstractPersistenceTest {
@MockitoBean
private MoodService moodService;
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
@Autowired @Autowired
@@ -19,8 +19,10 @@ import java.util.Base64;
*/ */
@Tag("IntegrationTest") @Tag("IntegrationTest")
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off. // The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off.
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false"}) properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev") @ActiveProfiles("dev")
public abstract class AbstractIntegrationTest { public abstract class AbstractIntegrationTest {