diff --git a/src/main/java/com/dostalgia/StaticResource.java b/src/main/java/com/dostalgia/StaticResource.java index b1935a8..7612ae6 100644 --- a/src/main/java/com/dostalgia/StaticResource.java +++ b/src/main/java/com/dostalgia/StaticResource.java @@ -2,7 +2,6 @@ package com.dostalgia; import jakarta.inject.Inject; import jakarta.ws.rs.GET; -import jakarta.ws.rs.HeaderParam; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -10,11 +9,8 @@ import jakarta.ws.rs.core.StreamingOutput; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.*; -import java.nio.channels.FileChannel; import java.nio.file.*; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** Serves game bundles (.jsdos) and artwork from the external data directory. */ @jakarta.ws.rs.Path("/") @@ -23,48 +19,11 @@ public class StaticResource { @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") String dataDir; - /** Pattern for HTTP Range header: bytes=start-end */ - private static final Pattern RANGE_PATTERN = Pattern.compile( - "^bytes=(\\d+)-(\\d*)$", Pattern.CASE_INSENSITIVE - ); - @GET @jakarta.ws.rs.Path("/games/{path: .*}") - public Response getGameFile( - @PathParam("path") String path, - @HeaderParam("Range") String rangeHeader - ) { - return serveFile(Path.of(dataDir, "games", path), rangeHeader, null); - } - - @GET - @jakarta.ws.rs.Path("/artwork/{path: .*}") - public Response getArtwork( - @PathParam("path") String path, - @HeaderParam("Range") String rangeHeader - ) { - return serveFile(Path.of(dataDir, "artwork", path), rangeHeader, - "public, max-age=86400"); - } - - /** Health check */ - @GET - @jakarta.ws.rs.Path("/api/health") - public Response health() { - return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build(); - } - - /** - * Serve a file with optional HTTP Range support. - *
- * js-dos v7 uses Range requests to lazily load ZIP entries from .jsdos - * bundles. Without proper Range handling, the entire multi-GB file gets - * streamed to the browser and cached in the OS page cache, keeping memory - * high long after play ends. - */ - private Response serveFile(Path file, String rangeHeader, String cacheControl) { - Path base = file.getParent().normalize(); - file = file.normalize(); + public Response getGameFile(@PathParam("path") String path) { + Path file = Path.of(dataDir, "games", path).normalize(); + Path base = Path.of(dataDir, "games").normalize(); if (!file.startsWith(base)) { return Response.status(403).build(); } @@ -73,72 +32,35 @@ public class StaticResource { } String contentType = guessContentType(file.getFileName().toString()); - long fileSize; - try { - fileSize = Files.size(file); - } catch (IOException e) { - return Response.serverError().build(); - } - - // No Range header — serve the full file (200 OK, chunked encoding) - if (rangeHeader == null || rangeHeader.isBlank()) { - Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize)) - .type(contentType) - .header("Accept-Ranges", "bytes"); - if (cacheControl != null) { - rb.header("Cache-Control", cacheControl); - } - return rb.build(); - } - - // Parse Range header: bytes=start-end - Matcher m = RANGE_PATTERN.matcher(rangeHeader.trim()); - if (!m.matches()) { - // Unrecognised range format — serve full file - return serveFull(file, contentType, fileSize, cacheControl); - } - - long start = Long.parseLong(m.group(1)); - long end; - if (m.group(2).isEmpty()) { - // Open-ended: bytes=N- → serve from N to end - end = fileSize - 1; - } else { - end = Long.parseLong(m.group(2)); - } - - // Validate - if (start >= fileSize || end < start) { - return Response.status(416) - .header("Content-Range", "bytes */" + fileSize) - .build(); - } - if (end >= fileSize) { - end = fileSize - 1; - } - - long contentLength = end - start + 1; - Response.ResponseBuilder rb = Response.status(206) - .entity(new FileStream(file, start, contentLength)) + return Response.ok(new FileStream(file)) .type(contentType) - .header("Accept-Ranges", "bytes") - .header("Content-Range", "bytes " + start + "-" + end + "/" + fileSize) - .header("Content-Length", contentLength); - if (cacheControl != null) { - rb.header("Cache-Control", cacheControl); - } - return rb.build(); + .build(); } - /** Convenience: full-file 200 response (chunked encoding, no Content-Length). */ - private Response serveFull(Path file, String contentType, long fileSize, String cacheControl) { - Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize)) - .type(contentType) - .header("Accept-Ranges", "bytes"); - if (cacheControl != null) { - rb.header("Cache-Control", cacheControl); + @GET + @jakarta.ws.rs.Path("/artwork/{path: .*}") + public Response getArtwork(@PathParam("path") String path) { + Path file = Path.of(dataDir, "artwork", path).normalize(); + Path base = Path.of(dataDir, "artwork").normalize(); + if (!file.startsWith(base)) { + return Response.status(403).build(); } - return rb.build(); + if (!Files.exists(file) || Files.isDirectory(file)) { + return Response.status(404).build(); + } + + String contentType = guessContentType(file.getFileName().toString()); + return Response.ok(new FileStream(file)) + .type(contentType) + .header("Cache-Control", "public, max-age=86400") + .build(); + } + + /** Health check */ + @GET + @jakarta.ws.rs.Path("/api/health") + public Response health() { + return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build(); } private String guessContentType(String name) { @@ -157,31 +79,11 @@ public class StaticResource { return MediaType.APPLICATION_OCTET_STREAM; } - /** - * Streaming file output that avoids loading the whole file into memory. - * Uses Files.copy for full-file (zero-copy via sendfile) and a position-limited - * transfer for HTTP Range requests. - */ - private record FileStream(Path file, long offset, long length) implements StreamingOutput { + /** Streaming file output that avoids loading the whole file into memory. */ + private record FileStream(Path file) implements StreamingOutput { @Override public void write(OutputStream output) throws IOException { - if (offset == 0 && length == Files.size(file)) { - // Full file — use simple Files.copy (proven working) - Files.copy(file, output); - } else { - // Partial content (Range request) — transfer only the requested bytes - try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) { - long remaining = length; - long pos = offset; - while (remaining > 0) { - long written = ch.transferTo(pos, remaining, - java.nio.channels.Channels.newChannel(output)); - if (written <= 0) break; - pos += written; - remaining -= written; - } - } - } + Files.copy(file, output); } } }