diff --git a/readme.md b/readme.md index a7a416f..ec225df 100644 --- a/readme.md +++ b/readme.md @@ -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. - Opt-in public profile at `/u/`. 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 - 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. - **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 @@ -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 | | `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-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/stats/public` | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 87f0a2a..ebf2e98 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -83,13 +83,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT } @Override - public String generateProfileTicket(long userId, List profileStats) { + public String generateProfileTicket(long userId, List profileStats, List sections) { 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) + .claim("sections", sections) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS)) .signWith(privateKey, Jwts.SIG.EdDSA) diff --git a/src/main/java/auth_service/modules/profile/api/PublicProfileController.java b/src/main/java/auth_service/modules/profile/api/PublicProfileController.java index 035aee8..efa02e5 100644 --- a/src/main/java/auth_service/modules/profile/api/PublicProfileController.java +++ b/src/main/java/auth_service/modules/profile/api/PublicProfileController.java @@ -1,6 +1,7 @@ package auth_service.modules.profile.api; 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.shared.api.dto.response.ErrorResponse; import lombok.RequiredArgsConstructor; @@ -26,8 +27,13 @@ public class PublicProfileController { public ResponseEntity getPublicProfile(@PathVariable("displayName") String displayName) { return publicProfileService.findPublicProfile(displayName) .>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) .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()); + } } diff --git a/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java b/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java index 49cb0fb..5ef0ece 100644 --- a/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java +++ b/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java @@ -1,8 +1,16 @@ package auth_service.modules.profile.api.dto; import java.time.LocalDate; +import java.util.List; /** * @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 sections) {} +} diff --git a/src/main/java/auth_service/modules/profile/model/PublicProfile.java b/src/main/java/auth_service/modules/profile/model/PublicProfile.java index 70641bf..97c759c 100644 --- a/src/main/java/auth_service/modules/profile/model/PublicProfile.java +++ b/src/main/java/auth_service/modules/profile/model/PublicProfile.java @@ -3,4 +3,8 @@ package auth_service.modules.profile.model; import java.time.LocalDate; import java.util.List; -public record PublicProfile(long userId, String displayName, LocalDate memberSince, List profileStats) {} +public record PublicProfile(long userId, String displayName, LocalDate memberSince, List profileStats, + Style style) { + + public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List sections) {} +} diff --git a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java index 7fa2f35..2eaeb7d 100644 --- a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java +++ b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java @@ -21,14 +21,19 @@ public class PublicProfileRepository { */ public Optional findPublicByDisplayName(String displayName) { 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 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.getTimestamp("registration_date") != 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(); } } 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 1cea701..7c5edff 100644 --- a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java +++ b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java @@ -24,6 +24,7 @@ public class PublicProfileService { */ public Optional findPublicProfile(String 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()))); } } diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index 53a9a51..dd22f97 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -3,6 +3,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.ProfileStyleRequest; 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; @@ -53,6 +54,13 @@ public class UserController { return ResponseEntity.noContent().build(); } + @PutMapping(value = "/settings/profile-style", version = "1.0") + public ResponseEntity 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") public ResponseEntity updateAppearance(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody AppearanceRequest request) { diff --git a/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java index cfa82d7..65a1a35 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java @@ -8,10 +8,13 @@ import jakarta.validation.constraints.Positive; * The frontend has a CSS preset for each allowed name. */ public record AppearanceRequest( - @NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent, - @NotNull @Pattern(regexp = "sharp|default|round") String corners, + @NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent, + @NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners, Boolean wideLayout, @Positive Long backgroundAppid, @Positive Long bannerAppid, Boolean showBanner) { + + public static final String ACCENTS = "violet|blue|green|orange|pink|red"; + public static final String CORNERS = "sharp|default|round"; } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java new file mode 100644 index 0000000..103660f --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java new file mode 100644 index 0000000..720d746 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java @@ -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 sections) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java index 224588d..0fc870e 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java @@ -6,5 +6,6 @@ import java.util.List; @Builder public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, - AppearanceResponse appearance, List profileStats) { + AppearanceResponse appearance, List profileStats, + ProfileStyleResponse profileStyle) { } diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index 4637889..a649271 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -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.ProfileStyleRequest; 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.ProfileStyleResponse; import auth_service.modules.user.api.dto.response.SettingsResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse; 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.UserSettings; import auth_service.shared.security.Role; @@ -36,6 +39,7 @@ public abstract class ApiMapper { .publicProfile(user.getSettings().isPublicProfile()) .appearance(toApi(user.getSettings().getAppearance())) .profileStats(user.getSettings().getProfileStats()) + .profileStyle(toApi(user.getSettings().getProfileStyle())) .build()) .build(); } @@ -45,6 +49,16 @@ public abstract class ApiMapper { 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) { // A missing showBanner keeps the banner on, the default. return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()), diff --git a/src/main/java/auth_service/modules/user/model/ProfileStyle.java b/src/main/java/auth_service/modules/user/model/ProfileStyle.java new file mode 100644 index 0000000..3d8af28 --- /dev/null +++ b/src/main/java/auth_service/modules/user/model/ProfileStyle.java @@ -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 sections) { + + public static final List 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(); + } +} diff --git a/src/main/java/auth_service/modules/user/model/UserSettings.java b/src/main/java/auth_service/modules/user/model/UserSettings.java index 3aef6bd..4869e18 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -13,6 +13,8 @@ public class UserSettings { private boolean publicProfile; @Builder.Default private Appearance appearance = Appearance.DEFAULT; + @Builder.Default + private ProfileStyle profileStyle = ProfileStyle.DEFAULT; /** The stat groups of Core the public profile shows. */ @Builder.Default private List profileStats = List.of(); diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepository.java b/src/main/java/auth_service/modules/user/persistence/UserRepository.java index 6dda51d..e52981a 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -2,6 +2,7 @@ package auth_service.modules.user.persistence; 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.User; import auth_service.modules.user.model.UserSettings; @@ -34,6 +35,8 @@ public interface UserRepository { void updateProfileStats(long userId, List groups); + void updateProfileStyle(long userId, ProfileStyle style); + Optional findSteamIdHolder(long steamId); /** diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index cc42270..fb623f8 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -2,6 +2,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.exception.UserNotFoundException; 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.User; import auth_service.modules.user.model.UserSettings; @@ -103,6 +104,17 @@ public class UserRepositoryImpl implements UserRepository { 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. */ diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index 9feec95..c1e166e 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java @@ -1,6 +1,7 @@ package auth_service.modules.user.persistence.entities; import auth_service.modules.user.model.Appearance; +import auth_service.modules.user.model.ProfileStyle; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -55,4 +56,20 @@ public class UserSettingsEntity { @JdbcTypeCode(SqlTypes.ARRAY) @Column(name = "profile_stats", nullable = false) private List 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 profileSections = new ArrayList<>(ProfileStyle.ALL_SECTIONS); } diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index c6ce456..798aaab 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -1,6 +1,7 @@ package auth_service.modules.user.persistence.mapper; 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.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; @@ -30,6 +31,10 @@ public abstract class EntityMapper { entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(), entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner())) .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(); } else { // Fallback default settings for users without an entry in the database 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 eb7787d..05acb16 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -3,6 +3,7 @@ package auth_service.modules.user.service; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; 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.SteamLinkOutcome; import auth_service.modules.user.model.User; @@ -105,4 +106,11 @@ public class UserService { public void updateProfileStats(long userId, List groups) { 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); + } } diff --git a/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java b/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java index 3321212..c5aabb4 100644 --- a/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java +++ b/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java @@ -9,6 +9,7 @@ import java.util.List; public interface ProfileTicketIssuer { /** * @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 profileStats); + String generateProfileTicket(long userId, List profileStats, List sections); } diff --git a/src/main/resources/db/migration/V16__Profile_Style.sql b/src/main/resources/db/migration/V16__Profile_Style.sql new file mode 100644 index 0000000..6cfd0e1 --- /dev/null +++ b/src/main/resources/db/migration/V16__Profile_Style.sql @@ -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; diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index 9efa5e6..a2acd1d 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -110,7 +110,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test 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(); assertEquals("public-profile", claims.get("typ", String.class)); @@ -122,15 +122,16 @@ class JwtServiceTest extends AbstractUnitTest { @Test 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(); assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); + assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class)); } @Test 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 diff --git a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java index 8f7ad6b..cfc5e54 100644 --- a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java +++ b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java @@ -84,6 +84,40 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { 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 void publicProfile_NameIgnoresCase() throws Exception { mockMvc.perform(get("/api/public/profiles/lOBO")) diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index de05ee9..173afc9 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -1,5 +1,6 @@ package auth_service.modules.user.api; +import auth_service.modules.user.model.ProfileStyle; import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.configuration.security.JwtService; import auth_service.shared.security.UserPrincipal; @@ -390,6 +391,62 @@ class UserControllerTest extends AbstractWebMvcTest { .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 void updateAppearance_StoresTheValues() throws Exception { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( diff --git a/src/test/java/auth_service/modules/user/model/ProfileStyleTest.java b/src/test/java/auth_service/modules/user/model/ProfileStyleTest.java new file mode 100644 index 0000000..a3afe9d --- /dev/null +++ b/src/test/java/auth_service/modules/user/model/ProfileStyleTest.java @@ -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()); + } +} diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index c853281..b665db7 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -1,5 +1,6 @@ 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.model.Appearance; import auth_service.modules.user.model.SteamIdHolder; @@ -280,6 +281,26 @@ class UserRepositoryTest extends AbstractPersistenceTest { .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 void updateProfileStats_IsReadBackAndKeptByUpdateSettings() { UserEntity userEntity = entityManager.persist(UserEntity.builder()