Skip to content
Merged
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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ PlayerCoordsAPI provides real-time access to your Minecraft player coordinates t
- Lightweight HTTP server running only on localhost providing your coordinates
- Client-side only - no server-side components needed
- Works in singleplayer and multiplayer
- Mod menu integration allowing you to enable/disable the API
- Mod menu integration allowing you to enable/disable the API and configure CORS

## 🚀 Installation

Expand All @@ -42,8 +42,8 @@ PlayerCoordsAPI provides real-time access to your Minecraft player coordinates t
"z": -789.12,
"yaw": 180.00,
"pitch": 12.50,
"world": "overworld",
"biome": "plains",
"world": "minecraft:overworld",
"biome": "minecraft:plains",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"username": "PlayerName"
}
Expand All @@ -58,25 +58,26 @@ PlayerCoordsAPI provides real-time access to your Minecraft player coordinates t
| `z` | `number` | North-South |
| `yaw` | `number` | Horizontal rotation (degrees) |
| `pitch` | `number` | Vertical rotation (degrees) |
| `world` | `string` | Minecraft world |
| `biome` | `string` | Minecraft biome |
| `world` | `string` | Minecraft world registry ID |
| `biome` | `string` | Minecraft biome registry ID |
| `uuid` | `string` | Player UUID |
| `username` | `string` | Player username |

### Error Responses

| Status | Message |
|--------|---------------------|
| `403` | Access denied |
| `403` | Access denied / Origin not allowed |
| `404` | Player not in world |
| `405` | Method not allowed |

## 🔒 Security

For security reasons, the API server:
- Only accepts connections from localhost `127.0.0.1`
- Only accepts connections from loopback addresses such as `127.0.0.1` and `::1`
- Runs on port `25565` by default
- Provides read-only access to player position data
- Allows requests from any origin (CORS `Access-Control-Allow-Origin: *`) for easy integration with web applications
- Uses a configurable CORS policy. By default it allows all origins for backward compatibility, but you can restrict it in the config screen

## 🛠️ Examples

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import fr.sukikui.playercoordsapi.config.CorsUtils;
import fr.sukikui.playercoordsapi.config.ModConfig;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
Expand All @@ -25,6 +27,13 @@
public class PlayerCoordsAPIClient implements ClientModInitializer {
private static final int PORT = 25565;
private static final long START_RETRY_DELAY_MS = 5_000L;
private static final String ALLOWED_METHODS = "GET, OPTIONS";
private static final String DEFAULT_ALLOWED_HEADERS = "Content-Type, Authorization";
private static final String ACCESS_DENIED_RESPONSE = "{\"error\": \"Access denied\"}";
private static final String ORIGIN_NOT_ALLOWED_RESPONSE = "{\"error\": \"Origin not allowed\"}";
private static final String NON_BROWSER_CLIENTS_NOT_ALLOWED_RESPONSE = "{\"error\": \"Non-browser local clients not allowed\"}";
private static final String METHOD_NOT_ALLOWED_RESPONSE = "{\"error\": \"Method not allowed\"}";
private static final String PLAYER_NOT_IN_WORLD_RESPONSE = "{\"error\": \"Player not in world\"}";

private HttpServer server;
private ExecutorService serverExecutor;
Expand Down Expand Up @@ -164,39 +173,82 @@ private void cleanupServerResources() {
}

private void handleCoordsRequest(HttpExchange exchange) throws IOException {
String method = exchange.getRequestMethod();
InetAddress remoteAddress = exchange.getRemoteAddress().getAddress();
if (remoteAddress == null || !remoteAddress.isLoopbackAddress()) {
sendResponse(exchange, 403, ACCESS_DENIED_RESPONSE, CorsDecision.noCors());
return;
}

if (method.equalsIgnoreCase("OPTIONS")) {
sendResponse(exchange, 204, null);
CorsDecision corsDecision = evaluateCorsDecision(exchange);
if (!corsDecision.allowed()) {
sendResponse(exchange, 403, corsDecision.errorResponse(), CorsDecision.noCors());
return;
}

if (!method.equalsIgnoreCase("GET")) {
exchange.getResponseHeaders().set("Allow", "GET, OPTIONS");
sendResponse(exchange, 405, "{\"error\": \"Method not allowed\"}");
String method = exchange.getRequestMethod();

if (method.equalsIgnoreCase("OPTIONS")) {
sendResponse(exchange, 204, null, corsDecision);
return;
}

// Check if the client is allowed to access (only localhost)
InetAddress remoteAddress = exchange.getRemoteAddress().getAddress();
if (remoteAddress == null || !remoteAddress.isLoopbackAddress()) {
sendResponse(exchange, 403, "{\"error\": \"Access denied\"}");
if (!method.equalsIgnoreCase("GET")) {
exchange.getResponseHeaders().set("Allow", ALLOWED_METHODS);
sendResponse(exchange, 405, METHOD_NOT_ALLOWED_RESPONSE, corsDecision);
return;
}

PlayerSnapshot snapshot = latestSnapshot;
if (snapshot != null) {
sendResponse(exchange, 200, snapshot.toJson());
sendResponse(exchange, 200, snapshot.toJson(), corsDecision);
} else {
sendResponse(exchange, 404, "{\"error\": \"Player not in world\"}");
sendResponse(exchange, 404, PLAYER_NOT_IN_WORLD_RESPONSE, corsDecision);
}
}

private void sendResponse(HttpExchange exchange, int statusCode, String response) throws IOException {
// Add CORS headers
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, OPTIONS");
exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type, Authorization");
private CorsDecision evaluateCorsDecision(HttpExchange exchange) {
ModConfig config = PlayerCoordsAPI.getConfig();
String requestOrigin = exchange.getRequestHeaders().getFirst("Origin");

if (requestOrigin == null || requestOrigin.isBlank()) {
return config.allowNonBrowserLocalClients
? CorsDecision.noCors()
: CorsDecision.denied(NON_BROWSER_CLIENTS_NOT_ALLOWED_RESPONSE);
}

if (config.corsPolicy == ModConfig.CorsPolicy.ALLOW_ALL) {
return CorsDecision.allowed("*", resolveAllowedHeaders(exchange), false);
}

if (!CorsUtils.isOriginAllowed(config, requestOrigin)) {
return CorsDecision.denied(ORIGIN_NOT_ALLOWED_RESPONSE);
}

return CorsUtils.normalizeOrigin(requestOrigin)
.map(origin -> CorsDecision.allowed(origin, resolveAllowedHeaders(exchange), true))
.orElseGet(() -> CorsDecision.denied(ORIGIN_NOT_ALLOWED_RESPONSE));
}

private String resolveAllowedHeaders(HttpExchange exchange) {
String requestedHeaders = exchange.getRequestHeaders().getFirst("Access-Control-Request-Headers");

if (requestedHeaders == null || requestedHeaders.isBlank()) {
return DEFAULT_ALLOWED_HEADERS;
}

return requestedHeaders;
}

private void sendResponse(HttpExchange exchange, int statusCode, String response, CorsDecision corsDecision) throws IOException {
if (corsDecision.allowOrigin() != null) {
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", corsDecision.allowOrigin());
exchange.getResponseHeaders().set("Access-Control-Allow-Methods", ALLOWED_METHODS);
exchange.getResponseHeaders().set("Access-Control-Allow-Headers", corsDecision.allowHeaders());

if (corsDecision.varyByOrigin()) {
exchange.getResponseHeaders().set("Vary", "Origin, Access-Control-Request-Headers");
}
}

if (response != null) {
byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8);
Expand Down Expand Up @@ -237,6 +289,20 @@ private static String escapeJson(String value) {
return escaped.toString();
}

private record CorsDecision(boolean allowed, String allowOrigin, String allowHeaders, boolean varyByOrigin, String errorResponse) {
private static CorsDecision allowed(String allowOrigin, String allowHeaders, boolean varyByOrigin) {
return new CorsDecision(true, allowOrigin, allowHeaders, varyByOrigin, null);
}

private static CorsDecision denied(String errorResponse) {
return new CorsDecision(false, null, null, false, errorResponse);
}

private static CorsDecision noCors() {
return new CorsDecision(true, null, null, false, null);
}
}

private record PlayerSnapshot(
double x,
double y,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

import com.terraformersmc.modmenu.api.ConfigScreenFactory;
import com.terraformersmc.modmenu.api.ModMenuApi;
import me.shedaniel.autoconfig.AutoConfigClient;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;

@Environment(EnvType.CLIENT)
public class ModMenuIntegration implements ModMenuApi {
@Override
public ConfigScreenFactory<?> getModConfigScreenFactory() {
return parent -> AutoConfigClient.getConfigScreen(ModConfig.class, parent).get();
return PlayerCoordsConfigScreen::new;
}
}
Loading