Skip to content

Commit 43234e2

Browse files
Brutus5000claude
andauthored
Add Player.currentAvatar FK and publish avatar update events (#1146)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent be0e931 commit 43234e2

7 files changed

Lines changed: 245 additions & 0 deletions

File tree

push-branch-image.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env sh
2+
# Build the current branch into a Docker image and push it as a tag.
3+
# Refuses to run on develop/main/master or in a detached HEAD state.
4+
#
5+
# Builds for linux/amd64 (x86_64) by default so the image runs on the real
6+
# servers even when building from an arm64 machine (Apple Silicon).
7+
#
8+
# The Dockerfile expects a prebuilt jar in build/libs/, so we run the Gradle
9+
# bootJar task first (skip with SKIP_BUILD=1 if you already built it).
10+
#
11+
# Usage: ./push-branch-image.sh
12+
# Env: IMAGE override the destination repo (default: faforever/faf-java-api)
13+
# PLATFORM override the target platform (default: linux/amd64)
14+
# SKIP_BUILD set to 1 to reuse an existing build/libs jar
15+
set -eu
16+
17+
IMAGE="${IMAGE:-faforever/faf-java-api}"
18+
PLATFORM="${PLATFORM:-linux/amd64}"
19+
20+
branch=$(git rev-parse --abbrev-ref HEAD)
21+
22+
case "$branch" in
23+
develop|main|master|HEAD)
24+
echo "Refusing to push '$branch' as a Docker tag." >&2
25+
exit 1
26+
;;
27+
esac
28+
29+
# Docker tags must match [A-Za-z0-9_][A-Za-z0-9_.-]{0,127}.
30+
# Replace anything else (most commonly '/' from branch prefixes) with '-'.
31+
tag=$(printf '%s' "$branch" | sed 's/[^A-Za-z0-9_.-]/-/g')
32+
33+
if [ "${SKIP_BUILD:-0}" != "1" ]; then
34+
echo "Building jar with ./gradlew bootJar"
35+
./gradlew bootJar -x test
36+
fi
37+
38+
echo "Building $IMAGE:$tag from branch $branch for $PLATFORM"
39+
# buildx with --push builds and pushes in one step. --provenance=false keeps
40+
# the registry tag pointing at a plain image manifest rather than a manifest
41+
# list, which some deployment tooling expects.
42+
docker buildx build \
43+
--platform "$PLATFORM" \
44+
--provenance=false \
45+
-t "$IMAGE:$tag" \
46+
--push \
47+
.
48+
49+
echo "Done: $IMAGE:$tag"

src/main/java/com/faforever/api/data/domain/AvatarAssignment.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ public class AvatarAssignment extends AbstractEntity<AvatarAssignment> implement
4141
@Relationship(Avatar.TYPE_NAME)
4242
private Avatar avatar;
4343

44+
/**
45+
* @deprecated The selected avatar is now tracked via {@link Player#getCurrentAvatar()}
46+
* (the {@code login.avatar_id} column). Update that relationship instead; this flag is
47+
* kept only for backwards compatibility with older clients and will be removed.
48+
*/
49+
@Deprecated(forRemoval = true)
4450
@Column(name = "selected")
4551
@UpdatePermission(expression = IsEntityOwner.EXPRESSION)
4652
@Audit(action = Action.UPDATE, logStatement = "Avatar ''{0}'' has been selected on player ''{1}''", logExpressions = {"${avatarAssignment.avatar.id}", "${avatarAssignment.player.id}"})

src/main/java/com/faforever/api/data/domain/Player.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@
22

33
import com.faforever.api.data.checks.IsEntityOwner;
44
import com.faforever.api.data.checks.Prefab;
5+
import com.faforever.api.data.hook.PlayerAvatarUpdateHook;
6+
import com.faforever.api.data.hook.PlayerAvatarValidationHook;
57
import com.faforever.api.security.elide.permission.AdminModerationReportCheck;
68
import com.github.jasminb.jsonapi.annotations.Type;
9+
import com.yahoo.elide.annotation.Audit;
10+
import com.yahoo.elide.annotation.Audit.Action;
711
import com.yahoo.elide.annotation.Include;
12+
import com.yahoo.elide.annotation.LifeCycleHookBinding;
813
import com.yahoo.elide.annotation.ReadPermission;
914
import com.yahoo.elide.annotation.UpdatePermission;
1015
import lombok.Setter;
1116
import org.hibernate.annotations.BatchSize;
1217

1318
import jakarta.persistence.Entity;
19+
import jakarta.persistence.FetchType;
20+
import jakarta.persistence.JoinColumn;
1421
import jakarta.persistence.ManyToMany;
22+
import jakarta.persistence.ManyToOne;
1523
import jakarta.persistence.OneToMany;
1624
import jakarta.persistence.OneToOne;
1725
import jakarta.persistence.Table;
@@ -29,9 +37,28 @@ public class Player extends Login {
2937
private ClanMembership clanMembership;
3038
private Set<NameRecord> names;
3139
private Set<AvatarAssignment> avatarAssignments;
40+
private Avatar currentAvatar;
3241
private Set<ModerationReport> reporterOnModerationReports;
3342
private Set<ModerationReport> reportedOnModerationReports;
3443

44+
@UpdatePermission(expression = IsEntityOwner.EXPRESSION)
45+
@LifeCycleHookBinding(
46+
operation = LifeCycleHookBinding.Operation.UPDATE,
47+
phase = LifeCycleHookBinding.TransactionPhase.PRECOMMIT,
48+
hook = PlayerAvatarValidationHook.class
49+
)
50+
@LifeCycleHookBinding(
51+
operation = LifeCycleHookBinding.Operation.UPDATE,
52+
phase = LifeCycleHookBinding.TransactionPhase.POSTCOMMIT,
53+
hook = PlayerAvatarUpdateHook.class
54+
)
55+
@Audit(action = Action.UPDATE, logStatement = "Avatar ''{0}'' has been selected on player ''{1}''", logExpressions = {"${player.currentAvatar.id}", "${player.id}"})
56+
@ManyToOne(fetch = FetchType.LAZY)
57+
@JoinColumn(name = "avatar_id")
58+
public Avatar getCurrentAvatar() {
59+
return currentAvatar;
60+
}
61+
3562
// Permission is managed by ClanMembership class
3663
@UpdatePermission(expression = Prefab.ALL)
3764
@OneToOne(mappedBy = "player")
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.faforever.api.data.hook;
2+
3+
import com.faforever.api.config.RabbitConfiguration;
4+
import com.faforever.api.data.domain.Avatar;
5+
import com.faforever.api.data.domain.Player;
6+
import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation;
7+
import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase;
8+
import com.yahoo.elide.core.lifecycle.LifeCycleHook;
9+
import com.yahoo.elide.core.security.ChangeSpec;
10+
import com.yahoo.elide.core.security.RequestScope;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.amqp.rabbit.core.RabbitTemplate;
14+
import org.springframework.stereotype.Component;
15+
16+
import java.util.HashMap;
17+
import java.util.Map;
18+
import java.util.Optional;
19+
20+
@Slf4j
21+
@Component
22+
@RequiredArgsConstructor
23+
public class PlayerAvatarUpdateHook implements LifeCycleHook<Player> {
24+
25+
public static final String ROUTING_KEY_PLAYER_AVATAR_UPDATE = "success.player_avatar.update";
26+
27+
private final RabbitTemplate rabbitTemplate;
28+
29+
@Override
30+
public void execute(Operation operation, TransactionPhase phase, Player player, RequestScope requestScope, Optional<ChangeSpec> changes) {
31+
final Avatar avatar = player.getCurrentAvatar();
32+
final Integer avatarId = avatar == null ? null : avatar.getId();
33+
34+
Map<String, Object> payload = new HashMap<>();
35+
payload.put("player_id", player.getId());
36+
payload.put("avatar_id", avatarId);
37+
38+
log.debug("Publishing player_avatar update: {}", payload);
39+
rabbitTemplate.convertAndSend(
40+
RabbitConfiguration.EXCHANGE_FAF_LOBBY,
41+
ROUTING_KEY_PLAYER_AVATAR_UPDATE,
42+
payload
43+
);
44+
}
45+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.faforever.api.data.hook;
2+
3+
import com.faforever.api.avatar.AvatarAssignmentRepository;
4+
import com.faforever.api.data.domain.Avatar;
5+
import com.faforever.api.data.domain.Player;
6+
import com.faforever.api.error.ApiException;
7+
import com.faforever.api.error.Error;
8+
import com.faforever.api.error.ErrorCode;
9+
import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation;
10+
import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase;
11+
import com.yahoo.elide.core.lifecycle.LifeCycleHook;
12+
import com.yahoo.elide.core.security.ChangeSpec;
13+
import com.yahoo.elide.core.security.RequestScope;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.stereotype.Component;
16+
17+
import java.util.Optional;
18+
19+
/**
20+
* Rejects assigning a {@code currentAvatar} that is not granted to the player. The
21+
* {@code IsEntityOwner} update permission only guarantees the caller edits their own Player row;
22+
* it does not verify the avatar belongs to them. Clearing the avatar (null) is always allowed.
23+
*/
24+
@Component
25+
@RequiredArgsConstructor
26+
public class PlayerAvatarValidationHook implements LifeCycleHook<Player> {
27+
28+
private final AvatarAssignmentRepository avatarAssignmentRepository;
29+
30+
@Override
31+
public void execute(Operation operation, TransactionPhase phase, Player player, RequestScope requestScope, Optional<ChangeSpec> changes) {
32+
final Avatar avatar = player.getCurrentAvatar();
33+
if (avatar == null) {
34+
return;
35+
}
36+
37+
avatarAssignmentRepository.findOneByAvatarIdAndPlayerId(avatar.getId(), player.getId())
38+
.orElseThrow(() -> new ApiException(new Error(ErrorCode.AVATAR_NOT_ASSIGNED, avatar.getId(), player.getId())));
39+
}
40+
}

src/main/java/com/faforever/api/error/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ public enum ErrorCode {
120120
STEAM_LOGIN_VALIDATION_FAILED(210, "Login via Steam failed", "Invalid OpenID redirect code"),
121121
MAP_VERSION_INVALID_RANGE(211, "Invalid map version", "The map version must be a whole number in range {0, number} to {1, number}."),
122122
MOD_VERSION_INVALID_RANGE(212, "Invalid mod version", "The mod version must be a whole number in range {0, number} to {1, number}."),
123+
AVATAR_NOT_ASSIGNED(213, "Avatar not assigned", "Avatar ''{0}'' is not assigned to player ''{1}''."),
123124
;
124125

125126

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.faforever.api.data.hook;
2+
3+
import com.faforever.api.avatar.AvatarAssignmentRepository;
4+
import com.faforever.api.data.domain.Avatar;
5+
import com.faforever.api.data.domain.AvatarAssignment;
6+
import com.faforever.api.data.domain.Player;
7+
import com.faforever.api.error.ApiException;
8+
import com.faforever.api.error.ErrorCode;
9+
import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation;
10+
import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase;
11+
import org.junit.jupiter.api.BeforeEach;
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.extension.ExtendWith;
14+
import org.mockito.Mock;
15+
import org.mockito.junit.jupiter.MockitoExtension;
16+
17+
import java.util.Optional;
18+
19+
import static com.faforever.api.error.ApiExceptionMatcher.hasErrorCode;
20+
import static org.hamcrest.MatcherAssert.assertThat;
21+
import static org.junit.jupiter.api.Assertions.assertThrows;
22+
import static org.mockito.Mockito.verifyNoInteractions;
23+
import static org.mockito.Mockito.when;
24+
25+
@ExtendWith(MockitoExtension.class)
26+
class PlayerAvatarValidationHookTest {
27+
28+
private static final int PLAYER_ID = 7;
29+
private static final int AVATAR_ID = 42;
30+
31+
@Mock
32+
private AvatarAssignmentRepository avatarAssignmentRepository;
33+
34+
private PlayerAvatarValidationHook instance;
35+
36+
@BeforeEach
37+
void setUp() {
38+
instance = new PlayerAvatarValidationHook(avatarAssignmentRepository);
39+
}
40+
41+
private static Player playerWithAvatar(Integer avatarId) {
42+
Avatar avatar = new Avatar();
43+
avatar.setId(avatarId);
44+
Player player = new Player();
45+
player.setId(PLAYER_ID);
46+
player.setCurrentAvatar(avatar);
47+
return player;
48+
}
49+
50+
@Test
51+
void allowsAssignedAvatar() {
52+
when(avatarAssignmentRepository.findOneByAvatarIdAndPlayerId(AVATAR_ID, PLAYER_ID))
53+
.thenReturn(Optional.of(new AvatarAssignment()));
54+
55+
instance.execute(Operation.UPDATE, TransactionPhase.PRECOMMIT, playerWithAvatar(AVATAR_ID), null, Optional.empty());
56+
}
57+
58+
@Test
59+
void rejectsUnassignedAvatar() {
60+
when(avatarAssignmentRepository.findOneByAvatarIdAndPlayerId(AVATAR_ID, PLAYER_ID))
61+
.thenReturn(Optional.empty());
62+
63+
ApiException result = assertThrows(ApiException.class, () ->
64+
instance.execute(Operation.UPDATE, TransactionPhase.PRECOMMIT, playerWithAvatar(AVATAR_ID), null, Optional.empty()));
65+
assertThat(result, hasErrorCode(ErrorCode.AVATAR_NOT_ASSIGNED));
66+
}
67+
68+
@Test
69+
void allowsClearingAvatar() {
70+
Player player = new Player();
71+
player.setId(PLAYER_ID);
72+
73+
instance.execute(Operation.UPDATE, TransactionPhase.PRECOMMIT, player, null, Optional.empty());
74+
75+
verifyNoInteractions(avatarAssignmentRepository);
76+
}
77+
}

0 commit comments

Comments
 (0)