Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/main/java/ti4/discord/JdaService.java
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ private static void initializeWhitelistedRoles() {
adminRoles.add(jda.getRoleById("1487725249398308884")); // Balacasi's server
adminRoles.add(jda.getRoleById("1500012691224395906")); // BEANS's server
adminRoles.add(jda.getRoleById("1516450864376578238")); // Stabar's Server
adminRoles.add(jda.getRoleById("1527947707518423150")); // niugnip's Server

adminRoles.removeIf(Objects::isNull);

Expand Down Expand Up @@ -532,6 +533,7 @@ private static void initializeWhitelistedRoles() {
developerRoles.add(jda.getRoleById("1487725369766449173")); // Balacasi's server
developerRoles.add(jda.getRoleById("1500012939326001263")); // BEANS's server
developerRoles.add(jda.getRoleById("1516450864376578238")); // Stabar's Server
developerRoles.add(jda.getRoleById("1527947972615209041")); // niugnip's Server

developerRoles.removeIf(Objects::isNull);

Expand All @@ -555,7 +557,7 @@ private static void initializeWhitelistedRoles() {
bothelperRoles.add(jda.getRoleById("1458845770672377993")); // Async Tredenary (Planetary Duck System)
bothelperRoles.add(jda.getRoleById("1458845518393246036")); // Async Quadrodenary (Dannel's Camp Ground)
bothelperRoles.add(jda.getRoleById("1088532690803884052")); // FoW Server
bothelperRoles.add(jda.getRoleById("1063464689218105354")); // FoW Server Game Admin
bothelperRoles.add(jda.getRoleById("1063464689218105354")); // FoW Server Game Supervisor
bothelperRoles.add(jda.getRoleById("1429853811891241128")); // FoW Server Chapter 2 Bothelper
bothelperRoles.add(jda.getRoleById("1429853811891241129")); // FoW Server Chapter 2 Game Supervisor
bothelperRoles.add(jda.getRoleById("1248693989193023519")); // Community Server
Expand All @@ -575,6 +577,7 @@ private static void initializeWhitelistedRoles() {
bothelperRoles.add(jda.getRoleById("1487725393673719950")); // Balacasi's server
bothelperRoles.add(jda.getRoleById("1500013009492246558")); // BEANS's server
bothelperRoles.add(jda.getRoleById("1516450864376578238")); // Stabar's Server
bothelperRoles.add(jda.getRoleById("1527947912108183686")); // niugnip's Server

bothelperRoles.removeIf(Objects::isNull);
}
Expand Down Expand Up @@ -622,6 +625,32 @@ public static boolean isProduction() {
return Constants.ASYNCTI4_HUB_SERVER_ID.equals(guildPrimaryID);
}

/**
* Whether a user holds any of the given roles, by user and role ID. Unlike CommandHelper#hasRole
* this needs no interaction event, so it also works from the web API where all we have is a
* Discord user ID. Reads straight from JDA's cache (see the MemberCachePolicy.ALL /
* ChunkingFilter.ALL setup above), so it stays a synchronous lookup rather than a REST call.
*
* <p>Roles that can't be resolved are skipped, and the result is false if none matched - callers
* gating access on a role should fail closed rather than treat "couldn't check" as a pass.
*/
public static boolean hasAnyRole(String userId, String... roleIds) {
if (jda == null || userId == null) {
return false;
}
for (String roleId : roleIds) {
Role role = roleId == null ? null : jda.getRoleById(roleId);
if (role == null) {
continue;
}
Member member = role.getGuild().getMemberById(userId);
if (member != null && member.getRoles().contains(role)) {
return true;
}
}
return false;
}

private static boolean isWhitelistedGuild(Guild guild) {
return guilds.stream().anyMatch(whitelistGuild -> whitelistGuild.getId().equals(guild.getId()))
|| Constants.EMOJI_FARM_SERVERS.containsKey(guild.getId());
Expand Down
115 changes: 114 additions & 1 deletion src/main/java/ti4/spring/api/webdata/GameWebDataController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@

import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import ti4.discord.JdaService;
import ti4.game.Game;
import ti4.game.Player;
import ti4.spring.context.RequestContext;
import ti4.spring.service.gameevent.GameEventDto;
import ti4.spring.service.gameevent.GameEventService;
Expand All @@ -22,6 +29,29 @@ public class GameWebDataController {
private final GameWebDataService gameWebDataService;
private final GameEventService gameEventService;

/**
* TEMPORARY: while the new FoW web UI is in limited testing, production access is restricted to
* FoW server staff. Deliberately scoped to that server's own roles rather than JdaService's
* bothelperRoles, which spans every Async and community server plus all admins/developers -
* far wider than this gate is meant to be. Applies to GMs too: most hold one of these anyway,
* and exempting them would leave nearly every FoW game reachable. Remove this and its two call
* sites to open the UI up.
*/
private static final String[] FOW_WEB_UI_TESTER_ROLE_IDS = {
"1088532690803884052", // FoW Server
"1063464689218105354", // FoW Server Game Supervisor
"1429853811891241128", // FoW Server Chapter 2 Bothelper
"1429853811891241129", // FoW Server Chapter 2 Game Supervisor
};

private static final String FOW_WEB_UI_RESTRICTED_MESSAGE =
"The new Fog of War web UI is in limited testing and is currently restricted to Fog of War server staff.";

private boolean canUseFowWebUi(String userId) {
// Only gated on production; local/dev bots keep working without the role.
return !JdaService.isProduction() || JdaService.hasAnyRole(userId, FOW_WEB_UI_TESTER_ROLE_IDS);
}

@GetMapping(value = "/web-data", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> get(@PathVariable String gameName) {
Game game = RequestContext.getGame();
Expand All @@ -34,6 +64,81 @@ public ResponseEntity<String> get(@PathVariable String gameName) {
return ResponseEntity.ok(gameWebDataService.getOrCompute(gameName));
}

/**
* Fog-of-War aware web-data endpoint. GM/owner gets the full unfiltered payload (or,
* with {@code asPlayer}, a preview of another player's fogged view for debugging).
* Participants always get their own fogged view. Everyone else is forbidden.
*/
@GetMapping(value = "/web-data-fow", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getFow(
@PathVariable String gameName, @RequestParam(required = false) String asPlayer) {
Game game = RequestContext.getGame();
if (game == null || !game.isFowMode()) {
return ResponseEntity.notFound().build();
}

String userId = getOptionalUserId();
if (userId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Authentication required to view Fog of War games");
}

if (!canUseFowWebUi(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(FOW_WEB_UI_RESTRICTED_MESSAGE);
}

if (isGm(game, userId)) {
if (asPlayer == null || asPlayer.isBlank()) {
return withViewerIsGmHeader(gameWebDataService.getOrCompute(gameName), true);
}
Player target = resolvePlayer(game, asPlayer);
if (target == null) {
return ResponseEntity.notFound().build();
}
return withViewerIsGmHeader(gameWebDataService.computeFiltered(game, target), true);
}

Player viewer = game.getPlayer(userId);
if (viewer == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("You must be participating in this game to view it");
}
return withViewerIsGmHeader(gameWebDataService.computeFiltered(game, viewer), false);
}

/**
* Whether the requesting user is GM/owner can't be derived from the response body alone: a GM
* previewing another player's view and that player's own real view are shaped identically. Callers
* that need to distinguish them (e.g. to keep showing "view as" controls while previewing) read this
* header instead.
*/
private ResponseEntity<String> withViewerIsGmHeader(String body, boolean isGm) {
return ResponseEntity.ok()
.header("X-Viewer-Is-Gm", String.valueOf(isGm))
.body(body);
}

private boolean isGm(Game game, String userId) {
return userId.equals(game.getOwnerID())
|| game.getPlayersWithGMRole().stream().anyMatch(p -> userId.equals(p.getUserID()));
}

private Player resolvePlayer(Game game, String idOrFaction) {
Player byUserId = game.getPlayer(idOrFaction);
if (byUserId != null) {
return byUserId;
}
return game.getPlayerFromColorOrFaction(idOrFaction);
}

private String getOptionalUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
return auth.getName();
}
return null;
}

@GetMapping(value = "/game-state", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<WebGameState> getGameState() {
Game game = RequestContext.getGame();
Expand All @@ -46,14 +151,22 @@ public ResponseEntity<WebGameState> getGameState() {
return ResponseEntity.ok(WebGameState.fromGame(game));
}

/**
* The event log's compact-map-state snapshots and movement/retreat diffs are built from the
* full, unfiltered game state - there's no fogged variant of "what changed," so this stays
* blocked for FoW games except for the GM/owner, who already sees the unfiltered view.
*/
@GetMapping(value = "/events", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<GameEventDto>> getEvents() {
Game game = RequestContext.getGame();
if (game == null) {
return ResponseEntity.notFound().build();
}
if (game.isFowMode()) {
return ResponseEntity.notFound().build();
String userId = getOptionalUserId();
if (userId == null || !isGm(game, userId) || !canUseFowWebUi(userId)) {
return ResponseEntity.notFound().build();
}
}
return ResponseEntity.ok(gameEventService.getEvents(game));
}
Expand Down
Loading
Loading