diff --git a/readme.md b/readme.md index ba1c603..36563c7 100644 --- a/readme.md +++ b/readme.md @@ -63,7 +63,9 @@ To Be Played is split into three repositories: - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games and average completion. Readable only with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat - groups the user shares (the ticket's `profileStats` claim, set in Auth); none by default. + groups the user shares (the ticket's `profileStats` claim, set in Auth); none by default. A section the user + hides (the ticket's `sections` claim: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS`) is null and runs no + query; a ticket without the claim shows all. ## Authentication diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index de42277..5929dc0 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -66,6 +66,10 @@ public class JwtService implements ProfileTicketVerifier { } } + private static Set names(List claim) { + return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()); + } + @Override public Optional verifyProfileTicket(String ticket) { try { @@ -75,10 +79,9 @@ public class JwtService implements ProfileTicketVerifier { } long userId = Long.parseLong(claims.get("profileUserId", String.class)); // A ticket of an Auth from before the claim has none: then the profile shows no stats. - Set profileStats = claims.get("profileStats") instanceof List groups - ? groups.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()) - : Set.of(); - return Optional.of(new ProfileTicket(userId, profileStats)); + Set profileStats = claims.get("profileStats") instanceof List groups ? names(groups) : Set.of(); + Set sections = claims.get("sections") instanceof List shown ? names(shown) : null; + return Optional.of(new ProfileTicket(userId, profileStats, sections)); } catch (Exception e) { return Optional.empty(); } diff --git a/src/main/java/core_service/modules/library/api/PublicProfileController.java b/src/main/java/core_service/modules/library/api/PublicProfileController.java index f369a39..87ed34e 100644 --- a/src/main/java/core_service/modules/library/api/PublicProfileController.java +++ b/src/main/java/core_service/modules/library/api/PublicProfileController.java @@ -29,7 +29,7 @@ public class PublicProfileController { @GetMapping("/library") public ResponseEntity getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket)) - .>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats()))) + .>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections()))) .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket"))); } diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java index 57d7f5d..339b874 100644 --- a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -6,8 +6,10 @@ import java.util.List; import java.util.Map; /** - * What a public profile shows. Never notes, priority or anything that identifies the Steam account. + * What a public profile shows. Never notes, priority or anything that identifies the Steam account. The fields of a + * section the user hides ({@link core_service.modules.library.domain.ProfileSection}) are null. * + * @param finishedGames FINISHED and COMPLETED; part of the summary, so it stays when the status counts are hidden * @param byStatus has every status, also those with 0 games * @param playing up to 8 games, most played first * @param finished up to 12 FINISHED or COMPLETED games, best rated first @@ -15,12 +17,13 @@ import java.util.Map; * @param stats only the stat groups the user shares, the others null; null when the user shares none */ public record PublicLibraryDto( - long totalGames, - long totalPlaytimeMinutes, + Long totalGames, + Long totalPlaytimeMinutes, + Long finishedGames, Map byStatus, List playing, List finished, - int perfectGames, + Integer perfectGames, Double averageCompletion, UserStatsDto stats ) { diff --git a/src/main/java/core_service/modules/library/domain/ProfileSection.java b/src/main/java/core_service/modules/library/domain/ProfileSection.java new file mode 100644 index 0000000..4006f53 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/ProfileSection.java @@ -0,0 +1,26 @@ +package core_service.modules.library.domain; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.Set; + +/** + * The parts of the public profile a user can hide. Core sends no data for a hidden part. + */ +public enum ProfileSection { + SUMMARY, STATUS, PLAYING, FINISHED, STATS; + + /** + * @param names from the profile ticket; null (a ticket from before the sections) shows all. Unknown names are + * left out. + */ + public static Set shown(Collection names) { + Set sections = EnumSet.noneOf(ProfileSection.class); + for (ProfileSection section : values()) { + if (names == null || names.contains(section.name())) { + sections.add(section); + } + } + return sections; + } +} diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 55be522..d19ba32 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -15,6 +15,7 @@ import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.Mood; +import core_service.modules.library.domain.ProfileSection; import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; @@ -47,6 +48,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -212,27 +214,45 @@ public class LibraryQueryService { * @param profileStats the stat group names of the profile ticket */ @Public - public PublicLibraryDto getPublicLibrary(Long userId, Collection profileStats) { - Map byStatus = userGameRepository.countEveryStatus(userId); - List playing = userGameRepository.findListedByStatusIn(userId, - List.of(GameStatus.PLAYING), - PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))); - List finished = userGameRepository.findListedByStatusIn(userId, - List.of(GameStatus.FINISHED, GameStatus.COMPLETED), - PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid")))); - Map meta = steamGameRepository.findAllById( - Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList()) + /** + * @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query + */ + public PublicLibraryDto getPublicLibrary(Long userId, Collection profileStats, Collection sectionNames) { + Set sections = ProfileSection.shown(sectionNames); + boolean summary = sections.contains(ProfileSection.SUMMARY); + Map byStatus = summary || sections.contains(ProfileSection.STATUS) + ? userGameRepository.countEveryStatus(userId) : null; + List playing = sections.contains(ProfileSection.PLAYING) + ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING), + PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))) + : null; + List finished = sections.contains(ProfileSection.FINISHED) + ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED), + PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid")))) + : null; + Map meta = steamGameRepository.findAllById(Stream.of(playing, finished) + .filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList()) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); - long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); - List started = startedGames(userGameRepository.findAchievementProgress(userId)); - Set shared = StatGroup.shared(profileStats); - return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, + List started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null; + Set shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); + return new PublicLibraryDto( + summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null, + summary ? userGameRepository.sumPlaytime(userId) : null, + summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null, + sections.contains(ProfileSection.STATUS) ? byStatus : null, toPublicGames(playing, meta), toPublicGames(finished, meta), - perfectGames(started), averageCompletion(started), + summary ? perfectGames(started) : null, + summary ? averageCompletion(started) : null, shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); } + /** + * @return null for a hidden section + */ private static List toPublicGames(List games, Map meta) { + if (games == null) { + return null; + } return games.stream() .map(ug -> new PublicLibraryDto.Game(ug.getAppid(), meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, diff --git a/src/main/java/core_service/shared/security/ProfileTicket.java b/src/main/java/core_service/shared/security/ProfileTicket.java index 8afe394..5ab661a 100644 --- a/src/main/java/core_service/shared/security/ProfileTicket.java +++ b/src/main/java/core_service/shared/security/ProfileTicket.java @@ -6,5 +6,6 @@ import java.util.Set; * A valid public-profile ticket from Auth. * * @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them + * @param sections the names of the profile sections the user shows; null for a ticket without them, which shows all */ -public record ProfileTicket(long userId, Set profileStats) {} +public record ProfileTicket(long userId, Set profileStats, Set sections) {} diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index c6e0953..c411c88 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -106,7 +106,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test void verifyProfileTicket_ReturnsTheUser() { - assertEquals(Optional.of(new ProfileTicket(42L, Set.of())), + assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000))); } @@ -116,7 +116,16 @@ class JwtServiceTest extends AbstractUnitTest { .claim("profileStats", List.of("PILE", "TREND")) .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); - assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"))), jwtService.verifyProfileTicket(ticket)); + assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket)); + } + + @Test + void verifyProfileTicket_ReadsTheShownSections() { + String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42") + .claim("sections", List.of("PLAYING", "STATS")) + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); + + assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket)); } @Test diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index bf23f51..4121a03 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -59,6 +59,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); } + private static String ticket(long userId, List profileStats, List sections) { + return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) + .claim("profileStats", profileStats).claim("sections", sections) + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); + } + private UserGame game(long appid, int playtime, GameStatus status, Integer rating) { UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now()); game.setStatus(status); @@ -94,6 +100,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.totalGames").value(3)) .andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260)) + .andExpect(jsonPath("$.finishedGames").value(2)) .andExpect(jsonPath("$.byStatus.FINISHED").value(1)) .andExpect(jsonPath("$.byStatus.COMPLETED").value(1)) .andExpect(jsonPath("$.playing", hasSize(1))) @@ -142,6 +149,41 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.stats.trend").value(nullValue())); } + @Test + void publicLibrary_HiddenSections_SendNoData() throws Exception { + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES"), List.of("PLAYING")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.playing", hasSize(1))) + .andExpect(jsonPath("$.totalGames").value(nullValue())) + .andExpect(jsonPath("$.totalPlaytimeMinutes").value(nullValue())) + .andExpect(jsonPath("$.finishedGames").value(nullValue())) + .andExpect(jsonPath("$.perfectGames").value(nullValue())) + .andExpect(jsonPath("$.byStatus").value(nullValue())) + .andExpect(jsonPath("$.finished").value(nullValue())) + .andExpect(jsonPath("$.stats").value(nullValue())); + } + + @Test + void publicLibrary_SummaryWithoutStatus_KeepsTheFinishedCountButNoStatusCounts() throws Exception { + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("SUMMARY")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalGames").value(3)) + .andExpect(jsonPath("$.finishedGames").value(2)) + .andExpect(jsonPath("$.byStatus").value(nullValue())); + } + + @Test + void publicLibrary_OnlyStatusShown_HasTheCountsButNoTotals() throws Exception { + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("STATUS", "MONEY")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.byStatus.FINISHED").value(1)) + .andExpect(jsonPath("$.totalGames").value(nullValue())) + .andExpect(jsonPath("$.playing").value(nullValue())); + } + @Test void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());