feat(profile): store the public profile look

This commit is contained in:
lobo committed 2026-09-28 14:20:14 +02:00
1 parent f86f3c2e63
commit dd4fcd1546
27 files changed
+308 -15

No files matched your search

+4 -1
View File
@@ -36,7 +36,9 @@ To Be Played is split into three repositories:
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. 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, - 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. Its `profileStats` claim 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: names the stat groups the user chose to show there (none by default), its `sections` claim the parts of the
profile the user shows. The profile has its own look (accent, corners, banner and background from own game art,
sections in order), apart from the app appearance, and the public answer sends it as `style`. The ticket is not a login:
both services reject every token with a `typ` claim on normal endpoints. both services reject every token with a `typ` claim on normal endpoints.
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by - **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
@@ -90,6 +92,7 @@ To Be Played is split into three repositories:
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `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/appearance` | access token |
| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | access token | | `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | access token |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid, sections}`, sections from `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS` in their order; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -83,13 +83,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
} }
@Override @Override
public String generateProfileTicket(long userId, List<String> profileStats) { public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) {
return Jwts.builder() return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and() .header().keyId(jwtConfig.getKid()).and()
.subject(PROFILE_TICKET_TYPE) .subject(PROFILE_TICKET_TYPE)
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE) .claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
.claim("profileUserId", String.valueOf(userId)) .claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats) .claim("profileStats", profileStats)
.claim("sections", sections)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS)) .expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
.signWith(privateKey, Jwts.SIG.EdDSA) .signWith(privateKey, Jwts.SIG.EdDSA)
@@ -1,6 +1,7 @@
package auth_service.modules.profile.api; package auth_service.modules.profile.api;
import auth_service.modules.profile.api.dto.PublicProfileResponse; import auth_service.modules.profile.api.dto.PublicProfileResponse;
import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.service.PublicProfileService; import auth_service.modules.profile.service.PublicProfileService;
import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.shared.api.dto.response.ErrorResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -26,8 +27,13 @@ public class PublicProfileController {
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) { public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
return publicProfileService.findPublicProfile(displayName) return publicProfileService.findPublicProfile(displayName)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse( .<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse(
p.profile().displayName(), p.profile().memberSince(), p.ticket()))) p.profile().displayName(), p.profile().memberSince(), p.ticket(), style(p.profile().style()))))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name"))); .body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
} }
private static PublicProfileResponse.Style style(PublicProfile.Style style) {
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(),
style.backgroundAppid(), style.sections());
}
} }
@@ -1,8 +1,16 @@
package auth_service.modules.profile.api.dto; package auth_service.modules.profile.api.dto;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
/** /**
* @param ticket for Core's public profile endpoint; valid for a few minutes * @param ticket for Core's public profile endpoint; valid for a few minutes
* @param style the look the owner picked for the profile
*/ */
public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket) {} public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket, Style style) {
/**
* @param sections the parts of the profile in their order; a part not listed is hidden
*/
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
}
@@ -3,4 +3,8 @@ package auth_service.modules.profile.model;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
public record PublicProfile(long userId, String displayName, LocalDate memberSince, List<String> profileStats) {} public record PublicProfile(long userId, String displayName, LocalDate memberSince, List<String> profileStats,
Style style) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
}
@@ -21,14 +21,19 @@ public class PublicProfileRepository {
*/ */
public Optional<PublicProfile> findPublicByDisplayName(String displayName) { public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query(""" return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date, s.profile_stats SELECT u.id, u.display_name, u.registration_date, s.profile_stats, s.profile_accent,
s.profile_corners, s.profile_banner_appid, s.profile_background_appid, s.profile_sections
FROM users u JOIN user_settings s ON s.user_id = u.id FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile
""", """,
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), (rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null 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())), List.of((String[]) rs.getArray("profile_stats").getArray()),
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
rs.getObject("profile_banner_appid", Long.class),
rs.getObject("profile_background_appid", Long.class),
List.of((String[]) rs.getArray("profile_sections").getArray()))),
displayName).stream().findFirst(); displayName).stream().findFirst();
} }
} }
@@ -24,6 +24,7 @@ public class PublicProfileService {
*/ */
public Optional<TicketedProfile> findPublicProfile(String displayName) { public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(displayName) return publicProfileRepository.findPublicByDisplayName(displayName)
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId(), profile.profileStats()))); .map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(
profile.userId(), profile.profileStats(), profile.style().sections())));
} }
} }
@@ -3,6 +3,7 @@ package auth_service.modules.user.api;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; 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.ProfileStatsRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.api.mapper.ApiMapper; import auth_service.modules.user.api.mapper.ApiMapper;
@@ -53,6 +54,13 @@ public class UserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/settings/profile-style", version = "1.0")
public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileStyleRequest request) {
this.userService.updateProfileStyle(principal.getIdAsLong(), ApiMapper.toDomain(request));
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/appearance", version = "1.0") @PutMapping(value = "/settings/appearance", version = "1.0")
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody AppearanceRequest request) { @Valid @RequestBody AppearanceRequest request) {
@@ -8,10 +8,13 @@ import jakarta.validation.constraints.Positive;
* The frontend has a CSS preset for each allowed name. * The frontend has a CSS preset for each allowed name.
*/ */
public record AppearanceRequest( public record AppearanceRequest(
@NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent, @NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
@NotNull @Pattern(regexp = "sharp|default|round") String corners, @NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
Boolean wideLayout, Boolean wideLayout,
@Positive Long backgroundAppid, @Positive Long backgroundAppid,
@Positive Long bannerAppid, @Positive Long bannerAppid,
Boolean showBanner) { Boolean showBanner) {
public static final String ACCENTS = "violet|blue|green|orange|pink|red";
public static final String CORNERS = "sharp|default|round";
} }
@@ -0,0 +1,18 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* @param sections the parts of the public profile in their order; a part not listed is hidden
*/
public record ProfileStyleRequest(
@NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
@NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
@Positive Long bannerAppid,
@Positive Long backgroundAppid,
@NotNull List<@NotNull @Pattern(regexp = "SUMMARY|STATUS|PLAYING|FINISHED|STATS") String> sections) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.user.api.dto.response;
import java.util.List;
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid,
List<String> sections) {
}
@@ -6,5 +6,6 @@ import java.util.List;
@Builder @Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, List<String> profileStats) { AppearanceResponse appearance, List<String> profileStats,
ProfileStyleResponse profileStyle) {
} }
@@ -2,11 +2,14 @@ package auth_service.modules.user.api.mapper;
import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.AppearanceResponse; import auth_service.modules.user.api.dto.response.AppearanceResponse;
import auth_service.modules.user.api.dto.response.ProfileStyleResponse;
import auth_service.modules.user.api.dto.response.SettingsResponse; import auth_service.modules.user.api.dto.response.SettingsResponse;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
@@ -36,6 +39,7 @@ public abstract class ApiMapper {
.publicProfile(user.getSettings().isPublicProfile()) .publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance())) .appearance(toApi(user.getSettings().getAppearance()))
.profileStats(user.getSettings().getProfileStats()) .profileStats(user.getSettings().getProfileStats())
.profileStyle(toApi(user.getSettings().getProfileStyle()))
.build()) .build())
.build(); .build();
} }
@@ -45,6 +49,16 @@ public abstract class ApiMapper {
appearance.backgroundAppid(), appearance.bannerAppid(), appearance.showBanner()); appearance.backgroundAppid(), appearance.bannerAppid(), appearance.showBanner());
} }
private static ProfileStyleResponse toApi(ProfileStyle style) {
return new ProfileStyleResponse(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
style.sections());
}
public static ProfileStyle toDomain(ProfileStyleRequest request) {
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid(),
request.sections());
}
public static Appearance toDomain(AppearanceRequest request) { public static Appearance toDomain(AppearanceRequest request) {
// A missing showBanner keeps the banner on, the default. // A missing showBanner keeps the banner on, the default.
return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()), return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()),
@@ -0,0 +1,26 @@
package auth_service.modules.user.model;
import java.util.List;
/**
* The look of the public profile, apart from the {@link Appearance} of the app.
*
* @param bannerAppid the Steam app whose art is the banner of the profile; null for none
* @param backgroundAppid the Steam app whose art fills the background of the profile; null for none
* @param sections the parts of the profile in their order; a part not listed is hidden
*/
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid,
List<String> sections) {
public static final List<String> ALL_SECTIONS = List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
public static final ProfileStyle DEFAULT = new ProfileStyle(Appearance.DEFAULT.accent(), Appearance.DEFAULT.corners(),
null, null, ALL_SECTIONS);
/**
* Keeps each section once, at its first place.
*/
public ProfileStyle {
sections = sections.stream().distinct().toList();
}
}
@@ -13,6 +13,8 @@ public class UserSettings {
private boolean publicProfile; private boolean publicProfile;
@Builder.Default @Builder.Default
private Appearance appearance = Appearance.DEFAULT; private Appearance appearance = Appearance.DEFAULT;
@Builder.Default
private ProfileStyle profileStyle = ProfileStyle.DEFAULT;
/** The stat groups of Core the public profile shows. */ /** The stat groups of Core the public profile shows. */
@Builder.Default @Builder.Default
private List<String> profileStats = List.of(); private List<String> profileStats = List.of();
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
@@ -34,6 +35,8 @@ public interface UserRepository {
void updateProfileStats(long userId, List<String> groups); void updateProfileStats(long userId, List<String> groups);
void updateProfileStyle(long userId, ProfileStyle style);
Optional<SteamIdHolder> findSteamIdHolder(long steamId); Optional<SteamIdHolder> findSteamIdHolder(long steamId);
/** /**
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
@@ -103,6 +104,17 @@ public class UserRepositoryImpl implements UserRepository {
settingsOf(userId).setProfileStats(new ArrayList<>(groups)); settingsOf(userId).setProfileStats(new ArrayList<>(groups));
} }
@Transactional
@Override
public void updateProfileStyle(long userId, ProfileStyle style) {
UserSettingsEntity settingsEntity = settingsOf(userId);
settingsEntity.setProfileAccent(style.accent());
settingsEntity.setProfileCorners(style.corners());
settingsEntity.setProfileBannerAppid(style.bannerAppid());
settingsEntity.setProfileBackgroundAppid(style.backgroundAppid());
settingsEntity.setProfileSections(new ArrayList<>(style.sections()));
}
/** /**
* The settings row of the user, created with the defaults of the user_settings columns when there is none. * The settings row of the user, created with the defaults of the user_settings columns when there is none.
*/ */
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence.entities; package auth_service.modules.user.persistence.entities;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@@ -55,4 +56,20 @@ public class UserSettingsEntity {
@JdbcTypeCode(SqlTypes.ARRAY) @JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_stats", nullable = false) @Column(name = "profile_stats", nullable = false)
private List<String> profileStats = new ArrayList<>(); private List<String> profileStats = new ArrayList<>();
@Column(name = "profile_accent", nullable = false)
private String profileAccent = ProfileStyle.DEFAULT.accent();
@Column(name = "profile_corners", nullable = false)
private String profileCorners = ProfileStyle.DEFAULT.corners();
@Column(name = "profile_banner_appid")
private Long profileBannerAppid;
@Column(name = "profile_background_appid")
private Long profileBackgroundAppid;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_sections", nullable = false)
private List<String> profileSections = new ArrayList<>(ProfileStyle.ALL_SECTIONS);
} }
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence.mapper; package auth_service.modules.user.persistence.mapper;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
@@ -30,6 +31,10 @@ public abstract class EntityMapper {
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(), entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner())) entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
.profileStats(List.copyOf(entity.getSettings().getProfileStats())) .profileStats(List.copyOf(entity.getSettings().getProfileStats()))
.profileStyle(new ProfileStyle(entity.getSettings().getProfileAccent(),
entity.getSettings().getProfileCorners(), entity.getSettings().getProfileBannerAppid(),
entity.getSettings().getProfileBackgroundAppid(),
List.copyOf(entity.getSettings().getProfileSections())))
.build(); .build();
} else { } else {
// Fallback default settings for users without an entry in the database // Fallback default settings for users without an entry in the database
@@ -3,6 +3,7 @@ package auth_service.modules.user.service;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.SteamLinkOutcome; import auth_service.modules.user.model.SteamLinkOutcome;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
@@ -105,4 +106,11 @@ public class UserService {
public void updateProfileStats(long userId, List<String> groups) { public void updateProfileStats(long userId, List<String> groups) {
userRepository.updateProfileStats(userId, groups.stream().distinct().toList()); userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
} }
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileStyle(long userId, ProfileStyle style) {
userRepository.updateProfileStyle(userId, style);
}
} }
@@ -9,6 +9,7 @@ import java.util.List;
public interface ProfileTicketIssuer { public interface ProfileTicketIssuer {
/** /**
* @param profileStats the stat groups of Core the profile shows * @param profileStats the stat groups of Core the profile shows
* @param sections the parts of the profile Core sends data for
*/ */
String generateProfileTicket(long userId, List<String> profileStats); String generateProfileTicket(long userId, List<String> profileStats, List<String> sections);
} }
@@ -0,0 +1,10 @@
-- The look of the public profile, apart from the app appearance. A profile starts with the accent and corners the
-- user has in the app. The sections are the parts of the profile page in their order; a part not listed is hidden.
ALTER TABLE user_settings
ADD COLUMN profile_accent VARCHAR(16) NOT NULL DEFAULT 'violet',
ADD COLUMN profile_corners VARCHAR(16) NOT NULL DEFAULT 'default',
ADD COLUMN profile_banner_appid BIGINT,
ADD COLUMN profile_background_appid BIGINT,
ADD COLUMN profile_sections VARCHAR(16)[] NOT NULL DEFAULT '{SUMMARY,STATUS,PLAYING,FINISHED,STATS}';
UPDATE user_settings SET profile_accent = accent, profile_corners = corners;
@@ -110,7 +110,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() { void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L, List.of()); var ticket = jwtService.generateProfileTicket(42L, List.of(), List.of());
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("public-profile", claims.get("typ", String.class)); assertEquals("public-profile", claims.get("typ", String.class));
@@ -122,15 +122,16 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void generateProfileTicket_CarriesTheSharedStatGroups() { void generateProfileTicket_CarriesTheSharedStatGroups() {
String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND")); String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND"), List.of("PLAYING", "STATS"));
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class));
} }
@Test @Test
void parseLoginToken_ProfileTicketIsNoLoginToken() { void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of()))); assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of(), List.of())));
} }
@Test @Test
@@ -84,6 +84,40 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
} }
@Test
void publicProfile_AnswersWithTheOwnersStyle() throws Exception {
jdbc.update("update user_settings set profile_accent = 'red', profile_corners = 'round', profile_banner_appid = 620, "
+ "profile_sections = '{PLAYING,SUMMARY}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.style.accent").value("red"))
.andExpect(jsonPath("$.style.corners").value("round"))
.andExpect(jsonPath("$.style.bannerAppid").value(620))
.andExpect(jsonPath("$.style.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.style.sections[0]").value("PLAYING"))
.andExpect(jsonPath("$.style.sections[1]").value("SUMMARY"));
}
@Test
void publicProfile_NewRowShowsEverySection() throws Exception {
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(jsonPath("$.style.accent").value("violet"))
.andExpect(jsonPath("$.style.sections.length()").value(5));
}
@Test
void publicProfile_TicketCarriesTheShownSections() throws Exception {
jdbc.update("update user_settings set profile_sections = '{PLAYING}' 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("PLAYING"), claims.get("sections", List.class));
}
@Test @Test
void publicProfile_NameIgnoresCase() throws Exception { void publicProfile_NameIgnoresCase() throws Exception {
mockMvc.perform(get("/api/public/profiles/lOBO")) mockMvc.perform(get("/api/public/profiles/lOBO"))
@@ -1,5 +1,6 @@
package auth_service.modules.user.api; package auth_service.modules.user.api;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService; import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
@@ -390,6 +391,62 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@Test
void getCurrentUserV1_ShowsTheProfileStyle() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStyle(new ProfileStyle("red", "sharp", 620L, null, List.of("PLAYING"))).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.profileStyle.accent").value("red"))
.andExpect(jsonPath("$.settings.profileStyle.corners").value("sharp"))
.andExpect(jsonPath("$.settings.profileStyle.bannerAppid").value(620))
.andExpect(jsonPath("$.settings.profileStyle.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.settings.profileStyle.sections[0]").value("PLAYING"));
}
@Test
void updateProfileStyle_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"green\",\"corners\":\"round\",\"bannerAppid\":620,"
+ "\"sections\":[\"STATS\",\"SUMMARY\"]}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, null, List.of("STATS", "SUMMARY")));
}
// Each body differs from a valid one in one field only, so the 400 comes from that field's constraint.
@ParameterizedTest
@ValueSource(strings = {
"{\"accent\":\"hotpink\",\"corners\":\"default\",\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"pointy\",\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0,\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620,\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[\"NOTES\"]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[null]}",
"{\"accent\":\"violet\",\"corners\":\"default\"}"
})
void updateProfileStyle_UnknownOrMissingValue_IsRejected(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-style")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test @Test
void updateAppearance_StoresTheValues() throws Exception { void updateAppearance_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -0,0 +1,17 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProfileStyleTest extends AbstractUnitTest {
@Test
void keepsEachSectionOnceAtItsFirstPlace() {
assertEquals(List.of("STATS", "PLAYING"),
new ProfileStyle("red", "round", 620L, null, List.of("STATS", "PLAYING", "STATS")).sections());
}
}
@@ -1,5 +1,6 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
@@ -280,6 +281,26 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.getSettings().getAppearance()); .getSettings().getAppearance());
} }
@Test
void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("style@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
.getSettings().getProfileStyle());
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L, List.of("FINISHED", "SUMMARY"));
userRepository.updateProfileStyle(userEntity.getId(), style);
entityManager.flush();
entityManager.clear();
assertEquals(style, userRepository.findByEmail("style@example.com").orElseThrow().getSettings().getProfileStyle());
}
@Test @Test
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() { void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder() UserEntity userEntity = entityManager.persist(UserEntity.builder()