diff --git a/readme.md b/readme.md index 9faf5f9..698b4fe 100644 --- a/readme.md +++ b/readme.md @@ -397,8 +397,11 @@ sequenceDiagram Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit (`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly -cleanup sends every user. The state carries `listed` (active, public profile, opt-in and a display name: `ListedUser`), -which Core uses for its owner lists. The call runs on the task executor, so neither the answer nor a database +cleanup sends every user. A change of the profile tiles sends it too. The state carries `listed` (active, public profile, opt-in and a display +name: `ListedUser`), which Core uses for its owner lists, `publicProfile` (active, public profile and a display name: +`PublicUser`) and `profileSections` (the profile parts the user shows, by the names of the profile ticket: +`ProfileSections`), which Core uses to let others follow the user and to leave out in the follower note what the profile +hides (#27). The call runs on the task executor, so neither the answer nor a database connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns. ```mermaid @@ -406,8 +409,8 @@ sequenceDiagram participant A as Auth participant C as Core A->>A: link / unlink / block / unblock / name change (commit) - A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed} - C->>C: blocked_user, listed_user, account_role_change, Steam ID in user_steam_sync, display name in account_name + A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections} + C->>C: blocked_user, listed_user, public_user (+ sections), account_role_change, Steam ID in user_steam_sync, display name in account_name Note over A,C: nightly 04:15 A->>C: PUT /internal/accounts/states (every user) ``` diff --git a/src/main/java/auth_service/modules/account/model/CoreAccountState.java b/src/main/java/auth_service/modules/account/model/CoreAccountState.java index af0aa44..f31f94e 100644 --- a/src/main/java/auth_service/modules/account/model/CoreAccountState.java +++ b/src/main/java/auth_service/modules/account/model/CoreAccountState.java @@ -1,13 +1,17 @@ package auth_service.modules.account.model; import java.time.Instant; +import java.util.List; /** * One user as Core copies it. * * @param roleChangedAt null when the role never changed on the admin page * @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser} + * @param publicProfile may be followed in Core, see {@link auth_service.shared.account.PublicUser} + * @param profileSections the profile parts the user shows ({@link auth_service.shared.account.ProfileSections}), + * null without a settings row */ public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt, - boolean listed) { + boolean listed, boolean publicProfile, List profileSections) { } diff --git a/src/main/java/auth_service/modules/account/repo/AccountRepository.java b/src/main/java/auth_service/modules/account/repo/AccountRepository.java index 908a3e9..6d57f60 100644 --- a/src/main/java/auth_service/modules/account/repo/AccountRepository.java +++ b/src/main/java/auth_service/modules/account/repo/AccountRepository.java @@ -5,6 +5,8 @@ import auth_service.shared.account.ActiveAdmins; import auth_service.shared.account.ActiveUser; import auth_service.shared.account.EmailAddress; import auth_service.shared.account.ListedUser; +import auth_service.shared.account.ProfileSections; +import auth_service.shared.account.PublicUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; @@ -12,9 +14,12 @@ import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.sql.Array; +import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -115,10 +120,21 @@ public class AccountRepository { new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"), !rs.getBoolean("enabled"), rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(), - rs.getBoolean("listed")); + rs.getBoolean("listed"), rs.getBoolean("public_profile"), sections(rs.getArray("profile_tiles"))); + + // Stored as "ID:SIZE", see PublicProfileRepository.tile. + private static List sections(Array storedTiles) throws SQLException { + if (storedTiles == null) { + return null; + } + return ProfileSections.of(Arrays.stream((String[]) storedTiles.getArray()) + .map(stored -> stored.replaceFirst(":[^:]*$", "")) + .toList()); + } private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, " - + "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed " + + "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed, " + + "COALESCE(" + PublicUser.of("u", "s") + ", FALSE) AS public_profile, s.profile_tiles " + "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id"; public Optional findState(long userId) { diff --git a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java index 2889add..a5c728a 100644 --- a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java +++ b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java @@ -2,16 +2,14 @@ package auth_service.modules.profile.service; import auth_service.modules.profile.model.PublicProfile; import auth_service.modules.profile.repo.PublicProfileRepository; +import auth_service.shared.account.ProfileSections; import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.Public; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; @Service @Public @@ -20,8 +18,6 @@ public class PublicProfileService { public record TicketedProfile(PublicProfile profile, String ticket) {} - private static final String STATS_PREFIX = "STATS_"; - private final PublicProfileRepository publicProfileRepository; private final ProfileTicketIssuer profileTicketIssuer; @@ -33,15 +29,9 @@ public class PublicProfileService { .map(profile -> new TicketedProfile(profile, ticket(profile))); } - // Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section. private String ticket(PublicProfile profile) { - Map> byStats = profile.tiles().stream().map(PublicProfile.Tile::id) - .collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX))); - List statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList(); - List sections = new ArrayList<>(byStats.get(false)); - if (!statGroups.isEmpty()) { - sections.add("STATS"); - } - return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), statGroups, sections); + List tileIds = profile.tiles().stream().map(PublicProfile.Tile::id).toList(); + return profileTicketIssuer.generateProfileTicket(profile.userId(), profile.role(), + ProfileSections.statGroups(tileIds), ProfileSections.of(tileIds)); } } diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index c4a098e..b1d2641 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -130,6 +130,7 @@ public class UserService { @CacheEvict(value = "users", key = "#userId") public void updateProfileTiles(long userId, List tiles) { userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles))); + accountStatePublisher.publish(userId); } /** diff --git a/src/main/java/auth_service/shared/account/ListedUser.java b/src/main/java/auth_service/shared/account/ListedUser.java index f991020..76f8a4e 100644 --- a/src/main/java/auth_service/shared/account/ListedUser.java +++ b/src/main/java/auth_service/shared/account/ListedUser.java @@ -10,7 +10,6 @@ public final class ListedUser { } public static String of(String user, String settings) { - return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + settings + ".show_in_owner_lists AND " - + user + ".display_name IS NOT NULL"; + return PublicUser.of(user, settings) + " AND " + settings + ".show_in_owner_lists"; } } diff --git a/src/main/java/auth_service/shared/account/ProfileSections.java b/src/main/java/auth_service/shared/account/ProfileSections.java new file mode 100644 index 0000000..d960ccc --- /dev/null +++ b/src/main/java/auth_service/shared/account/ProfileSections.java @@ -0,0 +1,40 @@ +package auth_service.shared.account; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * The profile parts a user shows, by the names of the profile ticket. Core knows sections, not tiles: every + * {@code STATS_} tile is one group of the single STATS section. + */ +public final class ProfileSections { + + private static final String STATS_PREFIX = "STATS_"; + private static final String STATS = "STATS"; + // The names Core stores; a tile id of any other kind is a user's own choice and says nothing to Core. + private static final Set KNOWN = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", STATS); + + private ProfileSections() { + } + + /** + * @param tileIds the tile ids without their size + */ + public static List of(List tileIds) { + List sections = new ArrayList<>(tileIds.stream().filter(KNOWN::contains).toList()); + if (tileIds.stream().anyMatch(id -> id.startsWith(STATS_PREFIX)) && !sections.contains(STATS)) { + sections.add(STATS); + } + return sections; + } + + /** + * @param tileIds the tile ids without their size + * @return the stat groups of Core, one per {@code STATS_} tile + */ + public static List statGroups(List tileIds) { + return tileIds.stream().filter(id -> id.startsWith(STATS_PREFIX)).map(id -> id.substring(STATS_PREFIX.length())) + .toList(); + } +} diff --git a/src/main/java/auth_service/shared/account/PublicUser.java b/src/main/java/auth_service/shared/account/PublicUser.java new file mode 100644 index 0000000..3997d55 --- /dev/null +++ b/src/main/java/auth_service/shared/account/PublicUser.java @@ -0,0 +1,16 @@ +package auth_service.shared.account; + +/** + * The rule Core uses to allow follows (#27): active, public profile on, with a display name. It matches + * {@code PublicProfileRepository.findPublicByDisplayName}. The settings alias may come from a LEFT JOIN: no settings + * row is not public. + */ +public final class PublicUser { + + private PublicUser() { + } + + public static String of(String user, String settings) { + return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + user + ".display_name IS NOT NULL"; + } +} diff --git a/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java b/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java index 9132283..03f621a 100644 --- a/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java +++ b/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java @@ -56,9 +56,11 @@ class AccountRepositoryTest extends AbstractPersistenceTest { void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() { jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice); - assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false)); - assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false), - new CoreAccountState(bob, null, null, false, null, false)); + assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false, + List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))); + assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false, false, + List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")), + new CoreAccountState(bob, null, null, false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED"))); assertThat(repository.findState(-1L)).isEmpty(); } @@ -272,4 +274,48 @@ class AccountRepositoryTest extends AbstractPersistenceTest { assertThat(activeTokens(alice)).isZero(); assertThat(activeTokens(bob)).isEqualTo(2); } + + @Test + void findStates_PublicProfileOnlyForAnActivePublicUserWithAName_NoOptInNeeded() { + long open = insertUser("open@example.com"); + jdbc.update("update users set display_name = 'Open' where id = ?", open); + jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = false where user_id = ?", open); + assertThat(repository.findState(open)).get().extracting(CoreAccountState::publicProfile).isEqualTo(true); + + String[] breakers = {"update users set enabled = false where id = ?", + "update users set deleted_at = now() where id = ?", + "update users set status = 'PENDING' where id = ?", + "update users set display_name = null where id = ?", + "update user_settings set public_profile = false where user_id = ?"}; + for (String breaker : breakers) { + jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Open' where id = ?", open); + jdbc.update("update user_settings set public_profile = true where user_id = ?", open); + jdbc.update(breaker, open); + + assertThat(repository.findState(open)).as(breaker).get().extracting(CoreAccountState::publicProfile).isEqualTo(false); + assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == open) + .extracting(CoreAccountState::publicProfile).containsExactly(false); + } + } + + @Test + void findStates_ProfileSectionsAreTheTileIdsWithAllStatsTilesCollapsedToOne() { + jdbc.update("update user_settings set profile_tiles = '{SUMMARY:L,STATS_BACKLOG:M,PLAYING:S,STATS_PLAYTIME:M}' where user_id = ?", + alice); + + assertThat(repository.findState(alice)).get().extracting(CoreAccountState::profileSections) + .isEqualTo(List.of("SUMMARY", "PLAYING", "STATS")); + assertThat(repository.findAllStates()).extracting(CoreAccountState::profileSections) + .containsExactly(List.of("SUMMARY", "PLAYING", "STATS"), List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")); + } + + @Test + void findStates_UserWithoutASettingsRow_HasNoSectionsAndIsNotPublic() { + jdbc.update("delete from user_settings where user_id = ?", alice); + + assertThat(repository.findState(alice)).get().satisfies(state -> { + assertThat(state.profileSections()).isNull(); + assertThat(state.publicProfile()).isFalse(); + }); + } } diff --git a/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java index 6f46665..abd4b41 100644 --- a/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java @@ -31,7 +31,7 @@ import static org.mockito.Mockito.when; class AccountCleanupServiceTest extends AbstractUnitTest { - private static final List STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false)); + private static final List STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false, false, null)); private static final Duration RETENTION = Duration.ofDays(30); diff --git a/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java b/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java index f769213..8941b31 100644 --- a/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java @@ -73,8 +73,8 @@ class CoreAccountServiceTest extends AbstractUnitTest { expect("replaceStates"); service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true, - Instant.parse("2026-10-04T12:00:00Z"), true))); - service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false))); + Instant.parse("2026-10-04T12:00:00Z"), true, true, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")))); + service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false, false, List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED")))); core.verify(); } diff --git a/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java b/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java index 412ec82..4eed980 100644 --- a/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java +++ b/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java @@ -35,7 +35,7 @@ import static org.mockito.Mockito.when; class CoreAccountStatePublisherTest extends AbstractUnitTest { - private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false); + private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false, false, null); @Mock private AccountRepository accountRepository; @@ -89,7 +89,7 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest { @Test void coreDownOnce_RetriesAfter10sAndSendsTheCurrentState() { - CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true); + CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true, false, null); when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE), Optional.of(newer)); doThrow(new RestClientException("Core down")).doNothing().when(coreAccountService).applyStates(any()); ArgumentCaptor retry = ArgumentCaptor.forClass(Runnable.class); diff --git a/src/test/java/auth_service/modules/profile/service/PublicProfileServiceTest.java b/src/test/java/auth_service/modules/profile/service/PublicProfileServiceTest.java new file mode 100644 index 0000000..cbc8b46 --- /dev/null +++ b/src/test/java/auth_service/modules/profile/service/PublicProfileServiceTest.java @@ -0,0 +1,47 @@ +package auth_service.modules.profile.service; + +import auth_service.modules.profile.model.PublicProfile; +import auth_service.modules.profile.repo.PublicProfileRepository; +import auth_service.shared.security.ProfileTicketIssuer; +import auth_service.shared.security.Role; +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PublicProfileServiceTest extends AbstractUnitTest { + + @Mock + private PublicProfileRepository publicProfileRepository; + + @Mock + private ProfileTicketIssuer profileTicketIssuer; + + @InjectMocks + private PublicProfileService service; + + @Test + void findPublicProfile_TicketCarriesTheSectionsAndTheStatGroups() { + PublicProfile profile = new PublicProfile(7L, Role.USER, "lobo", null, + new PublicProfile.Style(null, null, null, null, Map.of()), + List.of(new PublicProfile.Tile("SUMMARY", "M"), new PublicProfile.Tile("STATS_BACKLOG", "M"), + new PublicProfile.Tile("STATS_PLAYTIME", "S"))); + when(publicProfileRepository.findPublicByDisplayName("lobo")).thenReturn(Optional.of(profile)); + when(profileTicketIssuer.generateProfileTicket(anyLong(), any(), anyList(), anyList())).thenReturn("ticket"); + + service.findPublicProfile("lobo"); + + verify(profileTicketIssuer).generateProfileTicket(7L, Role.USER, List.of("BACKLOG", "PLAYTIME"), + List.of("SUMMARY", "STATS")); + } +} diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index 931e3fa..f2054e4 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -77,6 +77,13 @@ class UserServiceTest extends AbstractUnitTest { assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles()); } + @Test + void updateProfileTiles_PublishesTheAccountStateOnce() { + userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"))); + + verify(accountStatePublisher, times(1)).publish(1L); + } + @Test void updateStatChart_PutsTheFormOfTheGroup() { userService.updateStatChart(1L, "PLATFORMS", "PIE"); diff --git a/src/test/java/auth_service/shared/account/ProfileSectionsTest.java b/src/test/java/auth_service/shared/account/ProfileSectionsTest.java new file mode 100644 index 0000000..79e8a00 --- /dev/null +++ b/src/test/java/auth_service/shared/account/ProfileSectionsTest.java @@ -0,0 +1,32 @@ +package auth_service.shared.account; + +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProfileSectionsTest extends AbstractUnitTest { + + @Test + void of_KeepsThePartsInOrder() { + assertThat(ProfileSections.of(List.of("SUMMARY", "PLAYING", "FINISHED"))).containsExactly("SUMMARY", "PLAYING", "FINISHED"); + } + + @Test + void of_CollapsesEveryStatsTileToOneStatsSectionAtTheEnd() { + assertThat(ProfileSections.of(List.of("STATS_A", "SUMMARY", "STATS_B"))).containsExactly("SUMMARY", "STATS"); + } + + @Test + void of_NoTilesNoSections() { + assertThat(ProfileSections.of(List.of())).isEmpty(); + } + + @Test + void of_DropsTileIdsThatCoreDoesNotKnow() { + assertThat(ProfileSections.of(List.of("SUMMARY", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "OWN_TILE", "STATS"))) + .containsExactly("SUMMARY", "STATS"); + } +} diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index 5fd06af..07164d0 100644 --- a/src/test/resources/contract/auth-core-contract.json +++ b/src/test/resources/contract/auth-core-contract.json @@ -37,12 +37,12 @@ "applyStates": { "method": "POST", "path": "/internal/accounts/states", - "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true}]} + "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true, "publicProfile": true, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]} }, "replaceStates": { "method": "PUT", "path": "/internal/accounts/states", - "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false}]} + "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false, "publicProfile": false, "profileSections": ["SUMMARY", "STATUS", "PLAYING", "FINISHED"]}]} }, "purge": { "method": "POST",