Spaces:
Paused
Paused
| package com.example.space; | |
| import com.sun.net.httpserver.Headers; | |
| import com.sun.net.httpserver.HttpExchange; | |
| import com.sun.net.httpserver.HttpServer; | |
| import java.io.ByteArrayOutputStream; | |
| import java.io.File; | |
| import java.io.IOException; | |
| import java.io.InputStream; | |
| import java.io.OutputStream; | |
| import java.net.HttpURLConnection; | |
| import java.net.InetSocketAddress; | |
| import java.net.Socket; | |
| import java.net.URL; | |
| import java.net.URLDecoder; | |
| import java.nio.charset.StandardCharsets; | |
| import java.nio.file.Files; | |
| import java.nio.file.Path; | |
| import java.nio.file.Paths; | |
| import java.util.Arrays; | |
| import java.util.List; | |
| import java.util.Map; | |
| import java.util.concurrent.Executors; | |
| public final class SpaceProxy { | |
| private static final int DEFAULT_PORT = 7860; | |
| private static final int BACKEND_PORT = 8080; | |
| private static final String WEB_ROOT = "/app/web"; | |
| private static final String BACKEND_CLASSPATH = "/app/backend/classes:/app/backend/lib/*"; | |
| public static void main(String[] args) throws Exception { | |
| Process backendProcess = startBackend(); | |
| Runtime.getRuntime().addShutdownHook(new Thread(() -> { | |
| if (backendProcess.isAlive()) { | |
| backendProcess.destroy(); | |
| } | |
| })); | |
| waitForBackend(backendProcess); | |
| int port = readPort(); | |
| HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); | |
| server.createContext("/", exchange -> { | |
| String path = normalizePath(exchange.getRequestURI().getRawPath()); | |
| if (path.startsWith("/api/")) { | |
| proxyToBackend(exchange); | |
| return; | |
| } | |
| serveStatic(exchange, path); | |
| }); | |
| server.setExecutor(Executors.newCachedThreadPool()); | |
| server.start(); | |
| System.out.println("Space proxy started at http://0.0.0.0:" + port); | |
| backendProcess.onExit().thenRun(() -> { | |
| System.err.println("Backend process exited unexpectedly."); | |
| server.stop(0); | |
| System.exit(1); | |
| }); | |
| } | |
| private static Process startBackend() throws IOException { | |
| ProcessBuilder builder = new ProcessBuilder( | |
| "java", | |
| "-cp", | |
| BACKEND_CLASSPATH, | |
| "com.example.dorm.SimpleHttpServer" | |
| ); | |
| builder.directory(new File("/app")); | |
| builder.inheritIO(); | |
| return builder.start(); | |
| } | |
| private static void waitForBackend(Process backendProcess) throws InterruptedException, IOException { | |
| long deadline = System.currentTimeMillis() + 30000; | |
| while (System.currentTimeMillis() < deadline) { | |
| if (!backendProcess.isAlive()) { | |
| throw new IllegalStateException("Backend process exited before it became ready."); | |
| } | |
| try (Socket socket = new Socket()) { | |
| socket.connect(new InetSocketAddress("127.0.0.1", BACKEND_PORT), 500); | |
| return; | |
| } catch (IOException ignored) { | |
| Thread.sleep(250); | |
| } | |
| } | |
| throw new IllegalStateException("Timed out waiting for backend to start."); | |
| } | |
| private static int readPort() { | |
| String portValue = System.getenv("PORT"); | |
| if (portValue == null || portValue.trim().isEmpty()) { | |
| return DEFAULT_PORT; | |
| } | |
| try { | |
| return Integer.parseInt(portValue.trim()); | |
| } catch (NumberFormatException ex) { | |
| return DEFAULT_PORT; | |
| } | |
| } | |
| private static String normalizePath(String rawPath) { | |
| if (rawPath == null || rawPath.isEmpty()) { | |
| return "/"; | |
| } | |
| try { | |
| return URLDecoder.decode(rawPath, StandardCharsets.UTF_8.name()); | |
| } catch (Exception ex) { | |
| return rawPath; | |
| } | |
| } | |
| private static void serveStatic(HttpExchange exchange, String path) throws IOException { | |
| if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { | |
| exchange.sendResponseHeaders(204, -1); | |
| exchange.close(); | |
| return; | |
| } | |
| if (path.equals("/") || path.isEmpty()) { | |
| path = "/index.html"; | |
| } | |
| if (path.endsWith("/")) { | |
| path = path + "index.html"; | |
| } | |
| Path basePath = Paths.get(WEB_ROOT).toAbsolutePath().normalize(); | |
| String relativePath = path.startsWith("/") ? path.substring(1) : path; | |
| Path candidate = basePath.resolve(relativePath).normalize(); | |
| if (!candidate.startsWith(basePath) || !Files.isRegularFile(candidate)) { | |
| sendText(exchange, 404, "Not Found"); | |
| return; | |
| } | |
| String fileName = candidate.getFileName().toString(); | |
| byte[] bytes; | |
| String contentType; | |
| if ("index.html".equalsIgnoreCase(fileName)) { | |
| String html = Files.readString(candidate, StandardCharsets.UTF_8); | |
| html = injectFetchPatch(html); | |
| bytes = html.getBytes(StandardCharsets.UTF_8); | |
| contentType = "text/html; charset=UTF-8"; | |
| } else { | |
| bytes = Files.readAllBytes(candidate); | |
| contentType = guessContentType(fileName); | |
| } | |
| exchange.getResponseHeaders().add("Content-Type", contentType); | |
| exchange.sendResponseHeaders(200, bytes.length); | |
| try (OutputStream output = exchange.getResponseBody()) { | |
| output.write(bytes); | |
| } | |
| } | |
| private static String injectFetchPatch(String html) { | |
| String patch = "<script>(function(){var originalFetch=window.fetch;window.fetch=function(input,init){if(typeof input==='string'&&input.indexOf('http://localhost:8080/api')===0){input=input.replace('http://localhost:8080/api','/api');}else if(input&&input.url&&input.url.indexOf('http://localhost:8080/api')===0){input=new Request(input.url.replace('http://localhost:8080/api','/api'),input);}return originalFetch.call(window,input,init);};})();</script>"; | |
| int headIndex = html.indexOf("</head>"); | |
| if (headIndex < 0) { | |
| return patch + html; | |
| } | |
| return html.substring(0, headIndex) + patch + html.substring(headIndex); | |
| } | |
| private static void proxyToBackend(HttpExchange exchange) throws IOException { | |
| String path = exchange.getRequestURI().getRawPath(); | |
| String query = exchange.getRequestURI().getRawQuery(); | |
| String target = "http://127.0.0.1:" + BACKEND_PORT + path + (query == null ? "" : "?" + query); | |
| HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection(); | |
| connection.setInstanceFollowRedirects(false); | |
| connection.setRequestMethod(exchange.getRequestMethod()); | |
| connection.setConnectTimeout(5000); | |
| connection.setReadTimeout(30000); | |
| connection.setDoInput(true); | |
| copyRequestHeaders(exchange, connection); | |
| byte[] requestBody = readAllBytes(exchange.getRequestBody()); | |
| if (requestBody.length > 0 && allowsRequestBody(exchange.getRequestMethod())) { | |
| connection.setDoOutput(true); | |
| try (OutputStream output = connection.getOutputStream()) { | |
| output.write(requestBody); | |
| } | |
| } | |
| int status = connection.getResponseCode(); | |
| byte[] responseBody = readAllBytes(status >= 400 ? connection.getErrorStream() : connection.getInputStream()); | |
| Headers responseHeaders = exchange.getResponseHeaders(); | |
| copyResponseHeaders(connection, responseHeaders); | |
| long responseLength = responseBody == null ? 0 : responseBody.length; | |
| if (status == 204 || status == 304) { | |
| responseLength = -1; | |
| } | |
| exchange.sendResponseHeaders(status, responseLength); | |
| if (responseLength != -1) { | |
| try (OutputStream output = exchange.getResponseBody()) { | |
| if (responseBody != null) { | |
| output.write(responseBody); | |
| } | |
| } | |
| } else { | |
| exchange.getResponseBody().close(); | |
| } | |
| } | |
| private static void copyRequestHeaders(HttpExchange exchange, HttpURLConnection connection) { | |
| for (Map.Entry<String, List<String>> entry : exchange.getRequestHeaders().entrySet()) { | |
| String name = entry.getKey(); | |
| if (name == null || isHopByHopHeader(name) || "Host".equalsIgnoreCase(name)) { | |
| continue; | |
| } | |
| List<String> values = entry.getValue(); | |
| if (values == null || values.isEmpty()) { | |
| continue; | |
| } | |
| connection.setRequestProperty(name, String.join(",", values)); | |
| } | |
| } | |
| private static void copyResponseHeaders(HttpURLConnection connection, Headers responseHeaders) { | |
| for (Map.Entry<String, List<String>> entry : connection.getHeaderFields().entrySet()) { | |
| String name = entry.getKey(); | |
| if (name == null || isHopByHopHeader(name) || "Content-Length".equalsIgnoreCase(name)) { | |
| continue; | |
| } | |
| List<String> values = entry.getValue(); | |
| if (values == null) { | |
| continue; | |
| } | |
| for (String value : values) { | |
| if (value != null) { | |
| responseHeaders.add(name, value); | |
| } | |
| } | |
| } | |
| } | |
| private static boolean allowsRequestBody(String method) { | |
| return Arrays.asList("POST", "PUT", "PATCH", "DELETE").contains(method.toUpperCase()); | |
| } | |
| private static boolean isHopByHopHeader(String name) { | |
| return "Connection".equalsIgnoreCase(name) | |
| || "Keep-Alive".equalsIgnoreCase(name) | |
| || "Proxy-Authenticate".equalsIgnoreCase(name) | |
| || "Proxy-Authorization".equalsIgnoreCase(name) | |
| || "TE".equalsIgnoreCase(name) | |
| || "Trailer".equalsIgnoreCase(name) | |
| || "Transfer-Encoding".equalsIgnoreCase(name) | |
| || "Upgrade".equalsIgnoreCase(name) | |
| || "Content-Length".equalsIgnoreCase(name); | |
| } | |
| private static byte[] readAllBytes(InputStream inputStream) throws IOException { | |
| if (inputStream == null) { | |
| return new byte[0]; | |
| } | |
| try (InputStream in = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) { | |
| byte[] buffer = new byte[8192]; | |
| int read; | |
| while ((read = in.read(buffer)) != -1) { | |
| output.write(buffer, 0, read); | |
| } | |
| return output.toByteArray(); | |
| } | |
| } | |
| private static void sendText(HttpExchange exchange, int status, String text) throws IOException { | |
| byte[] bytes = text.getBytes(StandardCharsets.UTF_8); | |
| exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=UTF-8"); | |
| exchange.sendResponseHeaders(status, bytes.length); | |
| try (OutputStream output = exchange.getResponseBody()) { | |
| output.write(bytes); | |
| } | |
| } | |
| private static String guessContentType(String fileName) { | |
| String lowerName = fileName.toLowerCase(); | |
| if (lowerName.endsWith(".html") || lowerName.endsWith(".htm")) return "text/html; charset=UTF-8"; | |
| if (lowerName.endsWith(".js")) return "application/javascript; charset=UTF-8"; | |
| if (lowerName.endsWith(".css")) return "text/css; charset=UTF-8"; | |
| if (lowerName.endsWith(".json")) return "application/json; charset=UTF-8"; | |
| if (lowerName.endsWith(".txt")) return "text/plain; charset=UTF-8"; | |
| if (lowerName.endsWith(".svg")) return "image/svg+xml"; | |
| if (lowerName.endsWith(".png")) return "image/png"; | |
| if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) return "image/jpeg"; | |
| if (lowerName.endsWith(".gif")) return "image/gif"; | |
| if (lowerName.endsWith(".webp")) return "image/webp"; | |
| if (lowerName.endsWith(".ico")) return "image/x-icon"; | |
| if (lowerName.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; | |
| return "application/octet-stream"; | |
| } | |
| } | |