feat(profile): let users share stat groups
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s

Store the groups in user_settings.profile_stats and send them in the
profile ticket, so Core shows only these on the public profile.
This commit is contained in:
lobo committed 2026-09-27 07:16:10 +02:00
1 parent 4b2acef381
commit 4ec47f1b2c
22 files changed
+206 -25

No files matched your search

+3 -1
View File
@@ -35,7 +35,8 @@ To Be Played is split into three repositories:
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The ticket is not a login:
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim
names the stat groups the user chose to show there (none by default). The ticket is not a login:
both services reject every token with a `typ` claim on normal endpoints.
## API
@@ -52,6 +53,7 @@ To Be Played is split into three repositories:
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
| `PUT` | `/api/users/settings/appearance` | access token |
| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public |
@@ -77,12 +77,13 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
}
@Override
public String generateProfileTicket(long userId) {
public String generateProfileTicket(long userId, List<String> profileStats) {
return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.subject(PROFILE_TICKET_TYPE)
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
.claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
.signWith(privateKey, Jwts.SIG.EdDSA)
@@ -1,5 +1,6 @@
package auth_service.modules.profile.model;
import java.time.LocalDate;
import java.util.List;
public record PublicProfile(long userId, String displayName, LocalDate memberSince) {}
public record PublicProfile(long userId, String displayName, LocalDate memberSince, List<String> profileStats) {}
@@ -4,6 +4,7 @@ import auth_service.modules.profile.model.PublicProfile;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@@ -20,13 +21,14 @@ public class PublicProfileRepository {
*/
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date
SELECT u.id, u.display_name, u.registration_date, s.profile_stats
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND s.public_profile
""",
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null),
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
List.of((String[]) rs.getArray("profile_stats").getArray())),
displayName).stream().findFirst();
}
}
@@ -22,6 +22,6 @@ public class PublicProfileService {
*/
public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(displayName)
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId())));
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId(), profile.profileStats())));
}
}
@@ -2,6 +2,7 @@ package auth_service.modules.user.api;
import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.ProfileStatsRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.api.mapper.ApiMapper;
@@ -45,6 +46,13 @@ public class UserController {
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-stats", version = "1.0")
public ResponseEntity<Void> updateProfileStats(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileStatsRequest request) {
this.userService.updateProfileStats(principal.getIdAsLong(), request.groups());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/appearance", version = "1.0")
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody AppearanceRequest request) {
@@ -0,0 +1,13 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* @param groups the stat groups of Core the public profile shows; empty shows none
*/
public record ProfileStatsRequest(
@NotNull List<@NotNull @Pattern(regexp = "PILE|TOP_GAMES|TASTE|HABITS|FINISHED|TREND") String> groups) {
}
@@ -2,7 +2,9 @@ package auth_service.modules.user.api.dto.response;
import lombok.Builder;
import java.util.List;
@Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance) {
AppearanceResponse appearance, List<String> profileStats) {
}
@@ -30,6 +30,7 @@ public abstract class ApiMapper {
.language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance()))
.profileStats(user.getSettings().getProfileStats())
.build())
.build();
}
@@ -3,6 +3,8 @@ package auth_service.modules.user.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class UserSettings {
@@ -11,4 +13,7 @@ public class UserSettings {
private boolean publicProfile;
@Builder.Default
private Appearance appearance = Appearance.DEFAULT;
/** The stat groups of Core the public profile shows. */
@Builder.Default
private List<String> profileStats = List.of();
}
@@ -8,6 +8,7 @@ import auth_service.modules.user.model.UserSettings;
import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional;
/**
@@ -31,6 +32,8 @@ public interface UserRepository {
@Transactional
void updateAppearance(long userId, Appearance appearance);
void updateProfileStats(long userId, List<String> groups);
Optional<SteamIdHolder> findSteamIdHolder(long steamId);
/**
@@ -15,6 +15,8 @@ import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -86,21 +88,7 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional
@Override
public void updateAppearance(long userId, Appearance appearance) {
var userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) {
throw new UserNotFoundException("Could not find user with id " + userId);
}
var settingsEntity = userEntity.getSettings();
if (settingsEntity == null) {
// Same values as the defaults of the user_settings columns.
settingsEntity = new UserSettingsEntity();
settingsEntity.setUserId(userId);
settingsEntity.setLanguage("");
settingsEntity.setTheme("light");
userEntity.setSettings(settingsEntity);
}
UserSettingsEntity settingsEntity = settingsOf(userId);
settingsEntity.setAccent(appearance.accent());
settingsEntity.setCorners(appearance.corners());
settingsEntity.setWideLayout(appearance.wideLayout());
@@ -109,6 +97,31 @@ public class UserRepositoryImpl implements UserRepository {
settingsEntity.setShowBanner(appearance.showBanner());
}
@Transactional
@Override
public void updateProfileStats(long userId, List<String> groups) {
settingsOf(userId).setProfileStats(new ArrayList<>(groups));
}
/**
* The settings row of the user, created with the defaults of the user_settings columns when there is none.
*/
private UserSettingsEntity settingsOf(long userId) {
UserEntity userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) {
throw new UserNotFoundException("Could not find user with id " + userId);
}
UserSettingsEntity settingsEntity = userEntity.getSettings();
if (settingsEntity == null) {
settingsEntity = new UserSettingsEntity();
settingsEntity.setUserId(userId);
settingsEntity.setLanguage("");
settingsEntity.setTheme("light");
userEntity.setSettings(settingsEntity);
}
return settingsEntity;
}
@Override
public Optional<SteamIdHolder> findSteamIdHolder(long steamId) {
// A projection, not the entity: the bulk updates that follow would leave a loaded entity stale.
@@ -7,6 +7,11 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "user_settings")
@@ -46,4 +51,8 @@ public class UserSettingsEntity {
@Column(name = "show_banner", nullable = false)
private boolean showBanner = Appearance.DEFAULT.showBanner();
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_stats", nullable = false)
private List<String> profileStats = new ArrayList<>();
}
@@ -6,6 +6,8 @@ import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.modules.user.persistence.entities.UserSettingsEntity;
import java.util.List;
public abstract class EntityMapper {
private EntityMapper() {
@@ -26,6 +28,7 @@ public abstract class EntityMapper {
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
.profileStats(List.copyOf(entity.getSettings().getProfileStats()))
.build();
} else {
// Fallback default settings for users without an entry in the database
@@ -16,6 +16,7 @@ import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Slf4j
@@ -87,4 +88,13 @@ public class UserService {
public void updateAppearance(long userId, Appearance appearance) {
userRepository.updateAppearance(userId, appearance);
}
/**
* Stores each group once; the request may repeat one.
*/
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileStats(long userId, List<String> groups) {
userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
}
}
@@ -1,9 +1,14 @@
package auth_service.shared.security;
import java.util.List;
/**
* Port for the short-lived ticket that lets Core show the public profile data of one user. The ticket is signed
* like a login token but is no login: the login filters of Auth and Core reject it.
*/
public interface ProfileTicketIssuer {
String generateProfileTicket(long userId);
/**
* @param profileStats the stat groups of Core the profile shows
*/
String generateProfileTicket(long userId, List<String> profileStats);
}
@@ -0,0 +1,3 @@
-- The stat groups of Core's stats page the user shows on the public profile, e.g. {PILE,TREND}. Empty for everyone
-- until they pick some.
ALTER TABLE user_settings ADD COLUMN profile_stats VARCHAR(16)[] NOT NULL DEFAULT '{}';
@@ -3,6 +3,7 @@ package auth_service.configuration.security;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -104,7 +105,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L);
var ticket = jwtService.generateProfileTicket(42L, List.of());
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("public-profile", claims.get("typ", String.class));
@@ -114,9 +115,17 @@ class JwtServiceTest extends AbstractUnitTest {
assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s");
}
@Test
void generateProfileTicket_CarriesTheSharedStatGroups() {
String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND"));
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
}
@Test
void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L)));
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of())));
}
@Test
@@ -1,6 +1,7 @@
package auth_service.modules.profile.api;
import auth_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -13,8 +14,10 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -66,6 +69,19 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
var key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class));
assertEquals(List.of(), claims.get("profileStats", List.class));
}
@Test
void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception {
jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' where user_id = ?", publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
String ticket = body.replaceAll(".*\"ticket\"\\s*:\\s*\"([^\"]+)\".*", "$1");
PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
}
@Test
@@ -30,6 +30,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow;
@@ -347,6 +348,48 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.appearance.showBanner").value(true));
}
@Test
void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStats(List.of("PILE", "TREND")).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.profileStats[0]").value("PILE"))
.andExpect(jsonPath("$.settings.profileStats[1]").value("TREND"));
}
@Test
void updateProfileStats_StoresTheGroups() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-stats")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"groups\":[\"PILE\",\"TREND\"]}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStats(1L, List.of("PILE", "TREND"));
}
// OVERVIEW is public anyway, so it is no choice.
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"groups\":[\"OVERVIEW\"]}", "{\"groups\":[\"MONEY\"]}", "{\"groups\":[null]}"})
void updateProfileStats_UnknownOrMissingGroups_Is400(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-stats")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void updateAppearance_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -12,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@@ -253,6 +254,29 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.getSettings().getAppearance());
}
@Test
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("stats@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow()
.getSettings().getProfileStats());
userRepository.updateProfileStats(userEntity.getId(), List.of("PILE", "TREND"));
entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build());
entityManager.flush();
entityManager.clear();
assertEquals(List.of("PILE", "TREND"), userRepository.findByEmail("stats@example.com").orElseThrow()
.getSettings().getProfileStats());
}
@Test
void updateAppearance_UnknownUser() {
assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT));
@@ -15,6 +15,7 @@ import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -61,6 +62,13 @@ class UserServiceTest extends AbstractUnitTest {
assertThrows(UserNotFoundException.class, () -> userService.getUserByEmail(email));
}
@Test
void updateProfileStats_StoresEachGroupOnce() {
userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE"));
verify(userRepository).updateProfileStats(1L, List.of("PILE", "TREND"));
}
@Test
void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() {
var userSettings = UserSettings.builder().language("en").build();