Public Access
feat(profile): leave out hidden public profile sections
This commit is contained in:
1 parent
5c0c0c6a78
commit
07d287cd43
9 files changed
+134
-28
No files matched your search
@@ -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
|
||||
public Optional<ProfileTicket> 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<String> 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<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
|
||||
Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null;
|
||||
return Optional.of(new ProfileTicket(userId, profileStats, sections));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -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.<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)
|
||||
.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;
|
||||
|
||||
/**
|
||||
* 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<GameStatus, Long> byStatus,
|
||||
List<Game> playing,
|
||||
List<Game> finished,
|
||||
int perfectGames,
|
||||
Integer perfectGames,
|
||||
Double averageCompletion,
|
||||
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.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<String> profileStats) {
|
||||
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
|
||||
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
|
||||
List.of(GameStatus.PLAYING),
|
||||
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
|
||||
List<UserGame> 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<Long, SteamGame> 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<String> profileStats, Collection<String> sectionNames) {
|
||||
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
|
||||
boolean summary = sections.contains(ProfileSection.SUMMARY);
|
||||
Map<GameStatus, Long> byStatus = summary || sections.contains(ProfileSection.STATUS)
|
||||
? userGameRepository.countEveryStatus(userId) : null;
|
||||
List<UserGame> 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<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));
|
||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
|
||||
Set<StatGroup> shared = StatGroup.shared(profileStats);
|
||||
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
|
||||
List<AchievementProgress> started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null;
|
||||
Set<StatGroup> 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<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> 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,
|
||||
|
||||
@@ -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<String> profileStats) {}
|
||||
public record ProfileTicket(long userId, Set<String> profileStats, Set<String> sections) {}
|
||||
@@ -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
|
||||
|
||||
@@ -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<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) {
|
||||
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());
|
||||
|
||||
Reference in new issue
Block a user