Skip to content
41 changes: 41 additions & 0 deletions src/main/java/com/hytale/api/dto/request/PermissionRequests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.hytale.api.dto.request;

import java.util.List;

/**
* Request DTOs for permission and group operations.
*/
public final class PermissionRequests {
private PermissionRequests() {}

/**
* Request to add or remove an operator (player identifier: UUID or username).
*/
public record OpRequest(String player) {
public boolean isValid() {
return player != null && !player.isBlank();
}
}

/**
* Request to create a new permission group.
*/
public record CreateGroupRequest(String name, List<String> permissions) {
public boolean isValid() {
return name != null && !name.isBlank();
}

public List<String> effectivePermissions() {
return permissions != null ? permissions : List.of();
}
}

/**
* Request to update a group's permissions.
*/
public record UpdateGroupRequest(List<String> permissions) {
public List<String> effectivePermissions() {
return permissions != null ? permissions : List.of();
}
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/hytale/api/dto/response/ApiResponses.java
Original file line number Diff line number Diff line change
Expand Up @@ -447,4 +447,21 @@ public record MuteResponse(
String reason,
long expiresAt
) {}

/**
* Full permissions data (permissions.json structure).
*/
public record PermissionsDataResponse(
Map<String, GroupEntry> groups,
Map<String, UserEntry> users
) {
public record GroupEntry(List<String> permissions) {}

public record UserEntry(List<String> groups, List<String> permissions) {}
}

/**
* Single group response (name + permissions).
*/
public record GroupResponse(String name, List<String> permissions) {}
}
8 changes: 6 additions & 2 deletions src/main/java/com/hytale/api/http/ApiChannelInitializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ public ApiChannelInitializer(
// Initialize WebSocket manager
this.wsSessionManager = new WebSocketSessionManager(config.websocket());

// Initialize routers (sharable)
this.httpRouter = new HttpRequestRouter(config, tokenGenerator);
// Initialize routers (sharable) - server root is parent of mods folder
// Must use toAbsolutePath() first to normalize the path before getting parents
Path absolutePluginPath = pluginDataPath.toAbsolutePath();
Path modsFolder = absolutePluginPath.getParent();
Path serverRoot = modsFolder != null ? modsFolder.getParent() : absolutePluginPath;
this.httpRouter = new HttpRequestRouter(config, tokenGenerator, serverRoot);
this.webSocketHandler = new WebSocketHandler(config, tokenGenerator, wsSessionManager);
}

Expand Down
47 changes: 45 additions & 2 deletions src/main/java/com/hytale/api/http/HttpRequestRouter.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ public final class HttpRequestRouter extends SimpleChannelInboundHandler<FullHtt
// Chat pattern
private static final Pattern CHAT_MUTE = Pattern.compile("^/chat/mute/([a-fA-F0-9-]+)$");

// Permissions patterns
private static final Pattern SERVER_PERMISSIONS = Pattern.compile("^/server/permissions$");
private static final Pattern SERVER_PERMISSIONS_GROUPS = Pattern.compile("^/server/permissions/groups$");
private static final Pattern SERVER_PERMISSIONS_GROUPS_NAME = Pattern.compile("^/server/permissions/groups/([^/]+)$");
private static final Pattern SERVER_PERMISSIONS_OP = Pattern.compile("^/server/permissions/op$");
private static final Pattern SERVER_PERMISSIONS_OP_PLAYER = Pattern.compile("^/server/permissions/op/(.+)$");

private final ApiConfig config;
private final TokenGenerator tokenGenerator;

Expand All @@ -80,8 +87,9 @@ public final class HttpRequestRouter extends SimpleChannelInboundHandler<FullHtt
private final WorldExtendedHandler worldExtendedHandler;
private final ServerExtendedHandler serverExtendedHandler;
private final ChatHandler chatHandler;
private final PermissionsHandler permissionsHandler;

public HttpRequestRouter(ApiConfig config, TokenGenerator tokenGenerator) {
public HttpRequestRouter(ApiConfig config, TokenGenerator tokenGenerator, java.nio.file.Path serverRoot) {
this.config = config;
this.tokenGenerator = tokenGenerator;

Expand All @@ -97,10 +105,11 @@ public HttpRequestRouter(ApiConfig config, TokenGenerator tokenGenerator) {
// Initialize extended handlers
this.versionHandler = new VersionHandler();
this.playerInventoryHandler = new PlayerInventoryHandler();
this.playerExtendedHandler = new PlayerExtendedHandler();
this.worldExtendedHandler = new WorldExtendedHandler();
this.serverExtendedHandler = new ServerExtendedHandler();
this.chatHandler = new ChatHandler();
this.permissionsHandler = new PermissionsHandler(serverRoot, adminHandler);
this.playerExtendedHandler = new PlayerExtendedHandler(permissionsHandler, adminHandler);
}

@Override
Expand Down Expand Up @@ -183,6 +192,40 @@ private String route(ChannelHandlerContext ctx, FullHttpRequest request, HttpMet
return serverExtendedHandler.handleSave(request, identity);
}

// Server permissions - more specific patterns first
Matcher permGroupsNameMatcher = SERVER_PERMISSIONS_GROUPS_NAME.matcher(path);
if (permGroupsNameMatcher.matches()) {
String groupName = permGroupsNameMatcher.group(1);
if (method == HttpMethod.PUT) {
return permissionsHandler.handleUpdateGroup(request, identity, groupName);
}
if (method == HttpMethod.DELETE) {
return permissionsHandler.handleDeleteGroup(request, identity, groupName);
}
}

Matcher permOpPlayerMatcher = SERVER_PERMISSIONS_OP_PLAYER.matcher(path);
if (permOpPlayerMatcher.matches() && method == HttpMethod.DELETE) {
return permissionsHandler.handleRemoveOp(request, identity, permOpPlayerMatcher.group(1));
}

if (path.equals("/server/permissions") && method == HttpMethod.GET) {
return permissionsHandler.handleGetPermissions(request, identity);
}

if (path.equals("/server/permissions/groups")) {
if (method == HttpMethod.GET) {
return permissionsHandler.handleGetGroups(request, identity);
}
if (method == HttpMethod.POST) {
return permissionsHandler.handleCreateGroup(request, identity);
}
}

if (path.equals("/server/permissions/op") && method == HttpMethod.POST) {
return permissionsHandler.handleAddOp(request, identity);
}

// Players list
if (path.equals("/players") && method == HttpMethod.GET) {
return playersHandler.handleList(request, identity);
Expand Down
64 changes: 50 additions & 14 deletions src/main/java/com/hytale/api/http/handlers/AdminHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@
import com.hytale.api.exception.ApiException;
import com.hytale.api.security.ApiPermissions;
import com.hytale.api.security.ClientIdentity;
import com.hypixel.hytale.server.core.HytaleServer;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.console.ConsoleSender;
import com.hypixel.hytale.server.core.command.system.CommandManager;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.Universe;
import io.netty.handler.codec.http.FullHttpRequest;

import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;

/**
Expand Down Expand Up @@ -58,19 +61,48 @@ private String handleCommand(FullHttpRequest request, ClientIdentity identity) {
}

String command = sanitizeCommand(cmdRequest.command());
LOGGER.info("[API] Executing command by %s: %s".formatted(identity.clientId(), command));
return executeCommandUnchecked(command, identity);
}

try {
// Execute command through server's command manager
HytaleServer server = HytaleServer.get();
// Note: Command execution API may need adjustment based on actual server implementation
// This is a simplified version
auditLog("COMMAND", identity, "command=" + command);
/**
* Execute a server command (no permission check; caller must check SERVER_PERMISSIONS_WRITE or ADMIN_COMMAND).
* Uses CommandManager.handleCommand() with ConsoleSender for proper execution.
* Used by PermissionsHandler for /op add and /op remove.
*/
public String executeCommandUnchecked(String command, ClientIdentity identity) {
String sanitized = sanitizeCommand(command);
// Remove leading slash if present (CommandManager expects command without leading /)
if (sanitized.startsWith("/")) {
sanitized = sanitized.substring(1);
}

return GSON.toJson(new CommandResponse(
true,
"Command queued for execution: " + command
));
LOGGER.info("[API] Executing command by %s: %s".formatted(identity.clientId(), sanitized));

try {
auditLog("COMMAND", identity, "command=" + sanitized);

// Execute command using CommandManager with ConsoleSender (has all permissions)
CompletableFuture<Void> future = CommandManager.get()
.handleCommand(ConsoleSender.INSTANCE, sanitized);

// Wait briefly for command completion (most commands are fast)
// Use orTimeout to prevent indefinite blocking
try {
future.orTimeout(5, TimeUnit.SECONDS).join();
LOGGER.info("[API] Command executed successfully: " + sanitized);
return GSON.toJson(new CommandResponse(
true,
"Command executed: " + sanitized
));
} catch (Exception e) {
// Command may have completed but threw an exception, or timed out
// Still consider it "executed" as the command was dispatched
LOGGER.info("[API] Command dispatched (async): " + sanitized);
return GSON.toJson(new CommandResponse(
true,
"Command dispatched: " + sanitized
));
}
} catch (Exception e) {
LOGGER.warning("Command execution failed: " + e.getMessage());
return GSON.toJson(new CommandResponse(false, "Command failed: " + e.getMessage()));
Expand Down Expand Up @@ -207,16 +239,20 @@ private <T> T parseBody(FullHttpRequest request, Class<T> clazz) {

/**
* Find player by name or UUID.
* Note: Called from Netty HTTP thread. getPlayer(uuid) is a map lookup.
* getPlayer(name, NameMatching) touches world state and must not be called from this thread
* (causes "PlayerRef.getComponent called async with player in world"). If kick/ban by username
* ever throws that, resolve player on world thread or use command dispatch instead.
*/
private PlayerRef findPlayer(String identifier) {
Universe universe = Universe.get();

// Try UUID first
// Try UUID first (map lookup, safe from HTTP thread)
try {
UUID uuid = UUID.fromString(identifier);
return universe.getPlayer(uuid);
} catch (IllegalArgumentException e) {
// Not a UUID, search by name using exact matching
// Not a UUID: name lookup touches world thread; may throw from Netty thread
return universe.getPlayer(identifier, com.hypixel.hytale.server.core.NameMatching.EXACT);
}
}
Expand Down
Loading