feat(profile): leave out hidden public profile sections

This commit is contained in:
lobo committed 2026-09-28 14:20:14 +02:00
1 parent 5c0c0c6a78
commit 07d287cd43
9 files changed
+134 -28

No files matched your search

+3 -1
View File
@@ -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 - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion. Readable only 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 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 ## Authentication
@@ -66,6 +66,10 @@ public class JwtService implements ProfileTicketVerifier {
} }
} }
private static Set<String> names(List<?> claim) {
return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet());
}
@Override @Override
public Optional<ProfileTicket> verifyProfileTicket(String ticket) { public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try { try {
@@ -75,10 +79,9 @@ public class JwtService implements ProfileTicketVerifier {
} }
long userId = Long.parseLong(claims.get("profileUserId", String.class)); 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. // A ticket of an Auth from before the claim has none: then the profile shows no stats.
Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
? groups.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()) Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null;
: Set.of(); return Optional.of(new ProfileTicket(userId, profileStats, sections));
return Optional.of(new ProfileTicket(userId, profileStats));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -29,7 +29,7 @@ public class PublicProfileController {
@GetMapping("/library") @GetMapping("/library")
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket)) return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats()))) .<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket"))); .body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
} }
@@ -6,8 +6,10 @@ import java.util.List;
import java.util.Map; 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 byStatus has every status, also those with 0 games
* @param playing up to 8 games, most played first * @param playing up to 8 games, most played first
* @param finished up to 12 FINISHED or COMPLETED games, best rated 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 * @param stats only the stat groups the user shares, the others null; null when the user shares none
*/ */
public record PublicLibraryDto( public record PublicLibraryDto(
long totalGames, Long totalGames,
long totalPlaytimeMinutes, Long totalPlaytimeMinutes,
Long finishedGames,
Map<GameStatus, Long> byStatus, Map<GameStatus, Long> byStatus,
List<Game> playing, List<Game> playing,
List<Game> finished, List<Game> finished,
int perfectGames, Integer perfectGames,
Double averageCompletion, Double averageCompletion,
UserStatsDto stats UserStatsDto stats
) { ) {
@@ -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<ProfileSection> shown(Collection<String> names) {
Set<ProfileSection> sections = EnumSet.noneOf(ProfileSection.class);
for (ProfileSection section : values()) {
if (names == null || names.contains(section.name())) {
sections.add(section);
}
}
return sections;
}
}
@@ -15,6 +15,7 @@ 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.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood; 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.StatGroup;
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;
@@ -47,6 +48,7 @@ import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
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;
@@ -212,27 +214,45 @@ 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
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) { /**
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId); * @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId, */
List.of(GameStatus.PLAYING), public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))); Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
List<UserGame> finished = userGameRepository.findListedByStatusIn(userId, boolean summary = sections.contains(ProfileSection.SUMMARY);
List.of(GameStatus.FINISHED, GameStatus.COMPLETED), Map<GameStatus, Long> byStatus = summary || sections.contains(ProfileSection.STATUS)
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid")))); ? userGameRepository.countEveryStatus(userId) : null;
Map<Long, SteamGame> meta = steamGameRepository.findAllById( List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList()) ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING),
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))))
: null;
List<UserGame> 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<Long, SteamGame> 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)); .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); List<AchievementProgress> started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null;
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId)); Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
Set<StatGroup> shared = StatGroup.shared(profileStats); return new PublicLibraryDto(
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, 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), 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)); shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
} }
/**
* @return null for a hidden section
*/
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) { private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
if (games == null) {
return null;
}
return games.stream() return games.stream()
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(), .map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
@@ -6,5 +6,6 @@ import java.util.Set;
* A valid public-profile ticket from Auth. * 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 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<String> profileStats) {} public record ProfileTicket(long userId, Set<String> profileStats, Set<String> sections) {}
@@ -106,7 +106,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void verifyProfileTicket_ReturnsTheUser() { 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))); jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
} }
@@ -116,7 +116,16 @@ class JwtServiceTest extends AbstractUnitTest {
.claim("profileStats", List.of("PILE", "TREND")) .claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .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 @Test
@@ -59,6 +59,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
} }
private static String ticket(long userId, List<String> profileStats, List<String> 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) { private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now()); UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status); game.setStatus(status);
@@ -94,6 +100,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.totalGames").value(3)) .andExpect(jsonPath("$.totalGames").value(3))
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260)) .andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260))
.andExpect(jsonPath("$.finishedGames").value(2))
.andExpect(jsonPath("$.byStatus.FINISHED").value(1)) .andExpect(jsonPath("$.byStatus.FINISHED").value(1))
.andExpect(jsonPath("$.byStatus.COMPLETED").value(1)) .andExpect(jsonPath("$.byStatus.COMPLETED").value(1))
.andExpect(jsonPath("$.playing", hasSize(1))) .andExpect(jsonPath("$.playing", hasSize(1)))
@@ -142,6 +149,41 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.stats.trend").value(nullValue())); .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 @Test
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()); mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());