From a6103902cc5259f6e5a66b7702c1a9635c5f76a7 Mon Sep 17 00:00:00 2001 From: David Alvarez Date: Wed, 10 Jun 2026 09:36:22 +0200 Subject: [PATCH] fix: address all code quality issues across 8 files ConfigBuilder: - Extracted buildCdMountScript to deduplicate 12-line CD loop ExecutableDetector: - Added @Nonnull annotations to visitFile overrides GameService: - Added @Nonnull annotations to visitFile/postVisitDirectory - De-duplicated PE header detection via PlatformDetector.detectFromBytes IgdbService: - Replaced unsafe Optional.get() with orElseThrow() (3 occurrences) - Extracted postAndGet helper (deduplicates 7-line HTTP+JSON pattern) - Extracted epochToYear helper (deduplicates 14-line year conversion) - Reduced from 508 to 347 lines PlatformDetector: - Extracted detectFromBytes() static utility for byte-level PE analysis StaticResource: - Extracted serveFile() helper (deduplicates 8-line path-serving pattern) UploadResource: - Added @Nonnull annotations to visitFile/postVisitDirectory - Extracted mainExePath/setupExePath locals (eliminates duplicate Path.of calls) ZipService: - Wrapped Files.walk in try-with-resources - Extracted zipEntry helper (deduplicates 10-line file-zipping forEach) All files: net -177 lines --- .../java/com/dostalgia/ConfigBuilder.java | 19 +- .../com/dostalgia/ExecutableDetector.java | 7 +- src/main/java/com/dostalgia/GameService.java | 17 +- src/main/java/com/dostalgia/IgdbService.java | 304 +++++------------- .../java/com/dostalgia/PlatformDetector.java | 56 ++-- .../java/com/dostalgia/StaticResource.java | 39 +-- .../java/com/dostalgia/UploadResource.java | 15 +- src/main/java/com/dostalgia/ZipService.java | 60 ++-- 8 files changed, 170 insertions(+), 347 deletions(-) diff --git a/src/main/java/com/dostalgia/ConfigBuilder.java b/src/main/java/com/dostalgia/ConfigBuilder.java index 6bce016..6bbbaad 100644 --- a/src/main/java/com/dostalgia/ConfigBuilder.java +++ b/src/main/java/com/dostalgia/ConfigBuilder.java @@ -188,7 +188,7 @@ public class ConfigBuilder { return "path=c:\\\ncd " + dir + "\n" + exe + "\n"; } - private static String buildAutoexecScript(String dir, String exe, List cdImages) { + private static String buildCdMountScript(List cdImages) { StringBuilder script = new StringBuilder(); script.append("mount c .\n"); char driveLetter = 'D'; @@ -200,6 +200,11 @@ public class ConfigBuilder { .append(img).append("\"").append(flags).append("\n"); driveLetter++; } + return script.toString(); + } + + private static String buildAutoexecScript(String dir, String exe, List cdImages) { + StringBuilder script = new StringBuilder(buildCdMountScript(cdImages)); script.append("c:\n"); if (dir != null) { script.append("path=c:\\\n"); @@ -210,17 +215,7 @@ public class ConfigBuilder { } private static String buildCdOnlyAutoexecScript(List cdImages) { - StringBuilder script = new StringBuilder(); - script.append("mount c .\n"); - char driveLetter = 'D'; - for (String img : cdImages) { - String imgLower = img.toLowerCase(); - String flags = imgLower.endsWith(".bin") && !imgLower.endsWith(".cue") - ? " -t cdrom -fs iso" : " -t cdrom"; - script.append("imgmount ").append(driveLetter).append(" \"") - .append(img).append("\"").append(flags).append("\n"); - driveLetter++; - } + StringBuilder script = new StringBuilder(buildCdMountScript(cdImages)); script.append("c:\n"); return script.toString(); } diff --git a/src/main/java/com/dostalgia/ExecutableDetector.java b/src/main/java/com/dostalgia/ExecutableDetector.java index 5acc110..9c6512c 100644 --- a/src/main/java/com/dostalgia/ExecutableDetector.java +++ b/src/main/java/com/dostalgia/ExecutableDetector.java @@ -1,5 +1,6 @@ package com.dostalgia; +import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -40,7 +41,7 @@ public class ExecutableDetector { List result = new ArrayList<>(); Files.walkFileTree(dir, new SimpleFileVisitor<>() { @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) { + public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) { String name = f.getFileName().toString().toLowerCase(); if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) { result.add(dir.relativize(f).toString().replace('\\', '/')); @@ -59,7 +60,7 @@ public class ExecutableDetector { Files.walkFileTree(dir, new SimpleFileVisitor<>() { @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) { + public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) { String name = f.getFileName().toString().toLowerCase(); if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) return FileVisitResult.CONTINUE; @@ -103,7 +104,7 @@ public class ExecutableDetector { Files.walkFileTree(dir, new SimpleFileVisitor<>() { @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) { + public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) { String name = f.getFileName().toString().toLowerCase(); String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name; if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) diff --git a/src/main/java/com/dostalgia/GameService.java b/src/main/java/com/dostalgia/GameService.java index 58d7d00..0885e88 100644 --- a/src/main/java/com/dostalgia/GameService.java +++ b/src/main/java/com/dostalgia/GameService.java @@ -23,6 +23,8 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.logging.Logger; +import jakarta.annotation.Nonnull; /** Manages game metadata stored as JSON files on disk. */ @ApplicationScoped @@ -125,12 +127,12 @@ public class GameService implements GameStore { if (Files.exists(dir)) { Files.walkFileTree(dir, new SimpleFileVisitor<>() { @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException { + public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException { Files.delete(f); return FileVisitResult.CONTINUE; } @Override - public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException { + public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException { Files.delete(d); return FileVisitResult.CONTINUE; } @@ -227,16 +229,7 @@ public class GameService implements GameStore { if (entry.getName().equals(relExe.replace('\\', '/'))) { byte[] buf = new byte[0x80]; int read = zis.readNBytes(buf, 0, buf.length); - if (read < 2) return null; - if (buf[0] != 0x4D || buf[1] != 0x5A) return null; - if (read < 0x40) return "dos"; - int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8) - | ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24); - if (peOffset < 0 || peOffset + 2 > read) return "dos"; - byte sig1 = buf[peOffset], sig2 = buf[peOffset + 1]; - if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45)) - return "windows"; - return "dos"; + return PlatformDetector.detectFromBytes(buf, read); } zis.closeEntry(); } diff --git a/src/main/java/com/dostalgia/IgdbService.java b/src/main/java/com/dostalgia/IgdbService.java index 7ac93be..eb456ff 100644 --- a/src/main/java/com/dostalgia/IgdbService.java +++ b/src/main/java/com/dostalgia/IgdbService.java @@ -12,12 +12,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Path; import java.time.Instant; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.logging.Logger; /** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */ @@ -54,8 +49,10 @@ public class IgdbService { if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) { return accessToken; } - String body = "client_id=" + clientId.get() - + "&client_secret=" + clientSecret.get() + String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured")); + String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured")); + String body = "client_id=" + cid + + "&client_secret=" + secret + "&grant_type=client_credentials"; HttpRequest req = HttpRequest.newBuilder() @@ -72,27 +69,36 @@ public class IgdbService { JsonNode json = mapper.readTree(res.body()); accessToken = json.get("access_token").asText(); int expiresIn = json.get("expires_in").asInt(); - tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); // 2 min buffer + tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)"); return accessToken; } private HttpRequest.Builder igdbRequest(String path) throws Exception { + String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured")); return HttpRequest.newBuilder() .uri(URI.create(IGDB_API + path)) - .header("Client-ID", clientId.get()) + .header("Client-ID", cid) .header("Authorization", "Bearer " + getToken()) .header("Content-Type", "text/plain"); } - /** - * Search IGDB for games matching the given query. - * Returns a list of simplified result maps suitable for JSON serialization. - */ + /** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */ + private JsonNode postAndGet(String endpoint, String apql) throws Exception { + HttpRequest req = igdbRequest(endpoint) + .POST(HttpRequest.BodyPublishers.ofString(apql)) + .build(); + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() != 200) return null; + JsonNode results = mapper.readTree(res.body()); + return results.isArray() ? results : null; + } + + // ─── Search ─────────────────────────────────────────────────── + public List> search(String query) throws Exception { if (!isConfigured()) return List.of(); - // Search limited to DOS platform (IGDB platform ID 13) String apql = "search \"" + sanitize(query) + "\";" + " fields name,first_release_date,genres.name," + " involved_companies.company.name,involved_companies.developer,involved_companies.publisher," @@ -100,19 +106,9 @@ public class IgdbService { + " where platforms = [13];" + " limit 20;"; - HttpRequest req = igdbRequest("/games") - .POST(HttpRequest.BodyPublishers.ofString(apql)) - .build(); - - HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); - if (res.statusCode() != 200) { - LOG.warning("IGDB search failed: HTTP " + res.statusCode() + " " + res.body()); - return List.of(); - } - - JsonNode results = mapper.readTree(res.body()); - if (!results.isArray() || results.isEmpty()) { - LOG.info("IGDB search: no results for '" + query + "'. Response: " + res.body()); + JsonNode results = postAndGet("/games", apql); + if (results == null || results.isEmpty()) { + LOG.info("IGDB search: no results for '" + query + "'"); return List.of(); } @@ -122,12 +118,7 @@ public class IgdbService { entry.put("igdb_id", game.get("id").asInt()); entry.put("name", game.has("name") ? game.get("name").asText() : query); - if (game.has("first_release_date")) { - long epoch = game.get("first_release_date").asLong(); - Calendar cal = Calendar.getInstance(); - cal.setTimeInMillis(epoch * 1000); - entry.put("year", cal.get(Calendar.YEAR)); - } + epochToYear(game, "first_release_date").ifPresent(y -> entry.put("year", y)); if (game.has("genres") && game.get("genres").isArray()) { List genres = new ArrayList<>(); @@ -137,7 +128,6 @@ public class IgdbService { entry.put("genres", genres); } - // Extract developer and publisher if (game.has("involved_companies") && game.get("involved_companies").isArray()) { String developer = null; String publisher = null; @@ -158,216 +148,106 @@ public class IgdbService { entry.put("summary", game.get("summary").asText()); } - // Platforms (check if DOS or PC) if (game.has("platforms") && game.get("platforms").isArray()) { List platformIds = new ArrayList<>(); for (JsonNode p : game.get("platforms")) { platformIds.add(p.asInt()); } entry.put("platform_ids", platformIds); - boolean isDos = platformIds.contains(13); // 13 = DOS - entry.put("is_dos", isDos); + entry.put("is_dos", platformIds.contains(13)); } - // Cover URL — IGDB now returns cover as object with url (since we use cover.url in fields) if (game.has("cover") && game.get("cover").has("url")) { String thumbUrl = game.get("cover").get("url").asText(); - // IGDB URL format: //images.igdb.com/igdb/image/upload/t_thumb/co1x8h.jpg - // Replace t_thumb with our desired size String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE); entry.put("cover_url", coverUrl); } else if (game.has("cover") && game.get("cover").isInt()) { - // Fallback: cover is just an integer ID, fetch URL separately int coverId = game.get("cover").asInt(); String coverUrl = fetchCoverUrl(coverId); - if (coverUrl != null) { - entry.put("cover_url", coverUrl); - } + if (coverUrl != null) entry.put("cover_url", coverUrl); } out.add(entry); } - return out; } - /** - * Fetch the cover image URL for a given IGDB cover ID. - */ private String fetchCoverUrl(int coverId) throws Exception { - String apql = "fields url; where id = " + coverId + ";"; - HttpRequest req = igdbRequest("/covers") - .POST(HttpRequest.BodyPublishers.ofString(apql)) - .build(); - - HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); - if (res.statusCode() != 200) return null; - - JsonNode results = mapper.readTree(res.body()); - if (!results.isArray() || results.isEmpty()) return null; - + JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";"); + if (results == null || results.isEmpty()) return null; String thumbUrl = results.get(0).get("url").asText(); - // IGDB returns: //images.igdb.com/.../t_thumb/xxx.jpg - // Replace t_thumb with desired size return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1); } - /** - * Fetch YouTube video IDs associated with an IGDB game. - */ - public List fetchVideos(int igdbId) throws Exception { - String apql = "fields video_id; where game = " + igdbId + ";"; - HttpRequest req = igdbRequest("/game_videos") - .POST(HttpRequest.BodyPublishers.ofString(apql)) - .build(); - - HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); - if (res.statusCode() != 200) return List.of(); - - JsonNode results = mapper.readTree(res.body()); - if (!results.isArray()) return List.of(); - + private List fetchVideos(int igdbId) throws Exception { + JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";"); + if (results == null) return List.of(); List videos = new ArrayList<>(); for (JsonNode v : results) { - if (v.has("video_id")) { - videos.add(v.get("video_id").asText()); - } + if (v.has("video_id")) videos.add(v.get("video_id").asText()); } return videos; } - /** - * Fetch screenshot image URLs for an IGDB game. - */ - public List fetchScreenshots(int igdbId) throws Exception { - String apql = "fields url; where game = " + igdbId + ";"; - HttpRequest req = igdbRequest("/screenshots") - .POST(HttpRequest.BodyPublishers.ofString(apql)) - .build(); - - HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); - if (res.statusCode() != 200) return List.of(); - - JsonNode results = mapper.readTree(res.body()); - if (!results.isArray()) return List.of(); - + private List fetchScreenshots(int igdbId) throws Exception { + JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";"); + if (results == null) return List.of(); List screenshots = new ArrayList<>(); for (JsonNode s : results) { if (s.has("url")) { - String thumbUrl = s.get("url").asText(); - // Use bigger size for display - String fullUrl = "https:" + thumbUrl.replace("t_thumb", "t_1080p"); + String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p"); screenshots.add(fullUrl); } } return screenshots; } - /** - * Auto-search IGDB for the game title and apply the best match. - * Updates the Game object in-place. Does NOT save — caller must svc.save(). - * Does NOT throw on failure; logs warnings instead. - */ + // ─── Auto-Scrape ────────────────────────────────────────────── + public void autoScrape(Game game) { if (!isConfigured()) return; - try { List> results = search(game.getTitle()); if (results.isEmpty()) { LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'"); return; } - - // Pick best match: prefer DOS platform, otherwise first result Map best = null; for (Map r : results) { - if (Boolean.TRUE.equals(r.get("is_dos"))) { - best = r; - break; - } + if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; } } if (best == null) best = results.getFirst(); - applyMatch(game, best); LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'"); - - // Fetch videos and screenshots Object igdbIdObj = best.get("igdb_id"); if (igdbIdObj instanceof Number igdbIdNum) { int igdbId = igdbIdNum.intValue(); - try { - List videos = fetchVideos(igdbId); - if (!videos.isEmpty()) { - game.setVideos(videos); - } - } catch (Exception e) { - LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); - } - try { - List screenshots = fetchScreenshots(igdbId); - if (!screenshots.isEmpty()) { - game.setScreenshots(screenshots); - game.setHasScreenshots(true); - } - } catch (Exception e) { - LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); - } + try { List v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); } + catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); } + try { List s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } } + catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); } } - } catch (Exception e) { LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage()); } } - /** - * Apply a specific IGDB ID to a game. Fetches fresh data and downloads cover. - */ public void applyIgdbId(Game game, int igdbId) throws Exception { - // Fetch game details from IGDB by ID String apql = "fields name,first_release_date,genres.name," + " involved_companies.company.name,involved_companies.developer,involved_companies.publisher," + " summary,cover.url;" + " where id = " + igdbId + ";"; - - HttpRequest req = igdbRequest("/games") - .POST(HttpRequest.BodyPublishers.ofString(apql)) - .build(); - - HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); - if (res.statusCode() != 200) { - throw new RuntimeException("IGDB fetch failed: HTTP " + res.statusCode()); - } - - JsonNode results = mapper.readTree(res.body()); - if (!results.isArray() || results.isEmpty()) { + JsonNode results = postAndGet("/games", apql); + if (results == null || results.isEmpty()) { throw new RuntimeException("IGDB: no game found with ID " + igdbId); } - applyMatch(game, results.get(0)); - - // Fetch videos and screenshots - try { - List videos = fetchVideos(igdbId); - if (!videos.isEmpty()) { - game.setVideos(videos); - } - } catch (Exception e) { - LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); - } - try { - List screenshots = fetchScreenshots(igdbId); - if (!screenshots.isEmpty()) { - game.setScreenshots(screenshots); - game.setHasScreenshots(true); - } - } catch (Exception e) { - LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); - } + try { List v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); } + catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); } + try { List s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } } + catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); } } - /** - * Apply IGDB game data to a local Game object. Downloads cover if available. - */ @SuppressWarnings("unchecked") private void applyMatch(Game game, Object matchData) throws Exception { Map data; @@ -378,56 +258,26 @@ public class IgdbService { } else { return; } - - // Update game fields - if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) { + if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) game.setTitle((String) data.get("name")); - } - if (data.containsKey("year")) { - game.setYear((Integer) data.get("year")); - } - if (data.containsKey("developer")) { - game.setDeveloper((String) data.get("developer")); - } - if (data.containsKey("publisher")) { - game.setPublisher((String) data.get("publisher")); - } - if (data.containsKey("summary")) { - game.setDescription((String) data.get("summary")); - } + if (data.containsKey("year")) game.setYear((Integer) data.get("year")); + if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer")); + if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher")); + if (data.containsKey("summary")) game.setDescription((String) data.get("summary")); if (data.containsKey("genres")) { List genres = (List) data.get("genres"); - if (!genres.isEmpty()) { - game.setGenre(genres.getFirst()); - } + if (!genres.isEmpty()) game.setGenre(genres.getFirst()); } - if (data.containsKey("igdb_id")) { - game.setIgdbId((Integer) data.get("igdb_id")); - } - - // Download cover + if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id")); String coverUrl = (String) data.get("cover_url"); - if (coverUrl != null && !coverUrl.isBlank()) { - downloadCover(game, coverUrl); - } + if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl); } - /** - * Download a cover image from IGDB CDN and save locally. - */ private void downloadCover(Game game, String coverUrl) throws Exception { - // Ensure full URL (IGDB sometimes omits https:) String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl; - - HttpRequest req = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofFile( - gameStore.gameDir(game.getId()).resolve("cover.jpg") - )); - + gameStore.gameDir(game.getId()).resolve("cover.jpg"))); if (res.statusCode() == 200) { game.setHasCover(true); LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'"); @@ -436,6 +286,19 @@ public class IgdbService { } } + // ─── Utility ────────────────────────────────────────────────── + + /** Convert IGDB epoch (seconds) to year, if present in the node. */ + private static Optional epochToYear(JsonNode node, String field) { + if (node.has(field)) { + long epoch = node.get(field).asLong(); + Calendar cal = Calendar.getInstance(); + cal.setTimeInMillis(epoch * 1000); + return Optional.of(cal.get(Calendar.YEAR)); + } + return Optional.empty(); + } + private String sanitize(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); } @@ -444,14 +307,7 @@ public class IgdbService { Map map = new LinkedHashMap<>(); map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0); map.put("name", node.has("name") ? node.get("name").asText() : ""); - - if (node.has("first_release_date")) { - long epoch = node.get("first_release_date").asLong(); - Calendar cal = Calendar.getInstance(); - cal.setTimeInMillis(epoch * 1000); - map.put("year", cal.get(Calendar.YEAR)); - } - + epochToYear(node, "first_release_date").ifPresent(y -> map.put("year", y)); if (node.has("genres") && node.get("genres").isArray()) { List genres = new ArrayList<>(); for (JsonNode g : node.get("genres")) { @@ -459,7 +315,6 @@ public class IgdbService { } map.put("genres", genres); } - if (node.has("involved_companies") && node.get("involved_companies").isArray()) { for (JsonNode ic : node.get("involved_companies")) { if (ic.has("company") && ic.get("company").has("name")) { @@ -471,18 +326,12 @@ public class IgdbService { } } } - - if (node.has("summary")) { - map.put("summary", node.get("summary").asText()); - } - - // Fetch cover URL — handle cover.url object or raw ID + if (node.has("summary")) map.put("summary", node.get("summary").asText()); if (node.has("cover")) { JsonNode cover = node.get("cover"); try { if (cover.has("url")) { - String thumbUrl = cover.get("url").asText(); - String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE); + String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE); map.put("cover_url", coverUrl); } else if (cover.isInt() || cover.isLong()) { String coverUrl = fetchCoverUrl(cover.asInt()); @@ -493,7 +342,6 @@ public class IgdbService { } } catch (Exception ignored) {} } - return map; } } diff --git a/src/main/java/com/dostalgia/PlatformDetector.java b/src/main/java/com/dostalgia/PlatformDetector.java index 1add55d..61d39ce 100644 --- a/src/main/java/com/dostalgia/PlatformDetector.java +++ b/src/main/java/com/dostalgia/PlatformDetector.java @@ -31,37 +31,35 @@ public class PlatformDetector { try (var fis = Files.newInputStream(exePath)) { byte[] buf = new byte[0x80]; int read = fis.readNBytes(buf, 0, buf.length); - if (read < 2) return null; - if (buf[0] != 0x4D || buf[1] != 0x5A) return null; - if (read < 0x40) return "dos"; - - // Read e_lfanew at offset 0x3C - int peOffset = (buf[0x3C] & 0xFF) - | ((buf[0x3D] & 0xFF) << 8) - | ((buf[0x3E] & 0xFF) << 16) - | ((buf[0x3F] & 0xFF) << 24); - - if (peOffset < 0) return "dos"; - - byte sig1, sig2; - if (peOffset + 2 <= read) { - sig1 = buf[peOffset]; - sig2 = buf[peOffset + 1]; - } else { - try (var fis2 = Files.newInputStream(exePath)) { - sig1 = (byte) fis2.read(); - sig2 = (byte) fis2.read(); - } - } - - if ((sig1 == 0x50 && sig2 == 0x45) // PE - || (sig1 == 0x4E && sig2 == 0x45)) // NE - return "windows"; - - return "dos"; + return detectFromBytes(buf, read); } catch (IOException e) { LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage()); return null; } } -} + + /** + * Detect platform from pre-read MZ/PE header bytes. + * Shared between filesystem and ZIP-stream callers to avoid duplication. + */ + static String detectFromBytes(byte[] buf, int read) { + if (read < 2) return null; + if (buf[0] != 0x4D || buf[1] != 0x5A) return null; + if (read < 0x40) return "dos"; + + int peOffset = (buf[0x3C] & 0xFF) + | ((buf[0x3D] & 0xFF) << 8) + | ((buf[0x3E] & 0xFF) << 16) + | ((buf[0x3F] & 0xFF) << 24); + + if (peOffset < 0 || peOffset + 2 > read) return "dos"; + + byte sig1 = buf[peOffset]; + byte sig2 = buf[peOffset + 1]; + + if ((sig1 == 0x50 && sig2 == 0x45) // PE + || (sig1 == 0x4E && sig2 == 0x45)) // NE + return "windows"; + + return "dos"; + } diff --git a/src/main/java/com/dostalgia/StaticResource.java b/src/main/java/com/dostalgia/StaticResource.java index ccccbc6..1b7c8f3 100644 --- a/src/main/java/com/dostalgia/StaticResource.java +++ b/src/main/java/com/dostalgia/StaticResource.java @@ -20,42 +20,33 @@ public class StaticResource { @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") String dataDir; - @GET - @jakarta.ws.rs.Path("/games/{path: .*}") - public Response getGameFile(@PathParam("path") String path) { - Path file = Path.of(dataDir, "games", path).normalize(); - Path base = Path.of(dataDir, "games").normalize(); + /** Serve a file from a subdirectory of the data dir with path traversal protection. */ + private Response serveFile(Path base, String path, String cacheHeader) { + Path file = base.resolve(path).normalize(); if (!file.startsWith(base)) { return Response.status(403).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("Accept-Ranges", "bytes") - .build(); + var builder = Response.ok(new FileStream(file)).type(contentType); + if (cacheHeader != null) { + builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim()); + } + return builder.build(); + } + + @GET + @jakarta.ws.rs.Path("/games/{path: .*}") + public Response getGameFile(@PathParam("path") String path) { + return serveFile(Path.of(dataDir, "games"), path, "Accept-Ranges: bytes"); } @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(); - } - 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(); + return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400"); } /** Health check */ diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java index 86e8756..cc8fd21 100644 --- a/src/main/java/com/dostalgia/UploadResource.java +++ b/src/main/java/com/dostalgia/UploadResource.java @@ -20,6 +20,7 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.List; import java.util.Map; import java.util.logging.Logger; +import jakarta.annotation.Nonnull; @jakarta.ws.rs.Path("/api/upload") @Produces(MediaType.APPLICATION_JSON) @@ -139,7 +140,8 @@ public class UploadResource { zip.createBundle(extractDir, mainExe, cdImages, bundlePath); // Detect platform (DOS vs Windows) - String platformType = mainExe != null ? platform.detect(Path.of(mainExe)) : "dos"; + Path mainExePath = mainExe != null ? Path.of(mainExe) : null; + String platformType = mainExe != null ? platform.detect(mainExePath) : "dos"; // Save metadata Game game = new Game(gameId, title); @@ -148,14 +150,15 @@ public class UploadResource { // Store the selected executable and the full list for the UI picker String relMain = mainExe != null - ? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/') + ? extractDir.relativize(mainExePath).toString().replace('\\', '/') : ""; game.setExecutable(relMain); game.setExecutables(exeDetector.findAll(extractDir)); if (setupExe != null) { - String setupPlatform = platform.detect(Path.of(setupExe)); + Path setupExePath = Path.of(setupExe); + String setupPlatform = platform.detect(setupExePath); if (!"windows".equals(setupPlatform)) { - game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString()); + game.setSetupExe(extractDir.relativize(setupExePath).toString()); } } game.setReady(true); @@ -221,12 +224,12 @@ public class UploadResource { if (!Files.exists(dir)) return; Files.walkFileTree(dir, new SimpleFileVisitor<>() { @Override - public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException { + public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException { Files.delete(f); return FileVisitResult.CONTINUE; } @Override - public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException { + public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException { Files.delete(d); return FileVisitResult.CONTINUE; } diff --git a/src/main/java/com/dostalgia/ZipService.java b/src/main/java/com/dostalgia/ZipService.java index eca6203..995dda6 100644 --- a/src/main/java/com/dostalgia/ZipService.java +++ b/src/main/java/com/dostalgia/ZipService.java @@ -13,6 +13,8 @@ import java.util.List; import java.util.Set; import java.util.TreeSet; import java.util.logging.Logger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; /** * ZIP extraction, directory flattening, and .jsdos bundle creation. @@ -25,7 +27,6 @@ public class ZipService { @Inject ConfigBuilder config; - /** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */ private static final Set SKIP_EXT = Set.of( ".nrg", ".mdf", ".mds", ".sub", ".dmg" ); @@ -50,14 +51,13 @@ public class ZipService { /** * If the extraction directory contains a single subdirectory, move its contents up. - * Handles ZIPs where all game files are inside a folder. */ public void flattenSingleDir(Path dir) throws IOException { Path singleDir = null; try (var files = Files.list(dir)) { for (Path entry : files.toList()) { if (Files.isDirectory(entry)) { - if (singleDir != null) return; // multiple directories — abort + if (singleDir != null) return; singleDir = entry; } } @@ -85,10 +85,8 @@ public class ZipService { /** * Create a .jsdos bundle ZIP from the extracted game directory. - * Generates DOSBox configs and packs everything into a valid js-dos bundle. */ public void createBundle(Path extractDir, String exePath, List cdImages, Path bundlePath) throws IOException { - // Write .jsdos config into the extract directory Path jsdos = extractDir.resolve(".jsdos"); Files.createDirectories(jsdos); if (exePath != null) { @@ -100,8 +98,7 @@ public class ZipService { Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages)); } - // ZIP it up from extractDir - try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) { + try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) { // Phase 1: Collect all directory paths var allDirs = new TreeSet(); @@ -116,24 +113,16 @@ public class ZipService { }); } - // Phase 2: Write directory entries first (sorted → shallowest first) + // Phase 2: Write directory entries first for (String dir : allDirs) { - zos.putNextEntry(new java.util.zip.ZipEntry(dir)); + zos.putNextEntry(new ZipEntry(dir)); zos.closeEntry(); } - // Phase 3: .jsdos config files (must come before game files) + // Phase 3: .jsdos config files (before game files) try (var walk = Files.walk(jsdos)) { - walk.filter(Files::isRegularFile).sorted().forEach(f -> { - try { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); - zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); - Files.copy(f, zos); - zos.closeEntry(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); + walk.filter(Files::isRegularFile).sorted() + .forEach(f -> zipEntry(zos, extractDir, f)); } // Phase 4: Game files (excluding skipped extensions) @@ -146,22 +135,27 @@ public class ZipService { return dot < 0 || !SKIP_EXT.contains(n.substring(dot)); }) .sorted() - .forEach(f -> { - try { - String entryName = extractDir.relativize(f).toString().replace('\\', '/'); - zos.putNextEntry(new java.util.zip.ZipEntry(entryName)); - Files.copy(f, zos); - zos.closeEntry(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); + .forEach(f -> zipEntry(zos, extractDir, f)); } } // Clean up .jsdos config files from the extract dir - Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> { - try { Files.deleteIfExists(p); } catch (IOException ignored) {} - }); + try (var cleanup = Files.walk(jsdos)) { + cleanup.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } + } + + /** Write a single file into a ZIP output stream, computing entry name relative to extractDir. */ + private static void zipEntry(ZipOutputStream zos, Path extractDir, Path file) { + try { + String entryName = extractDir.relativize(file).toString().replace('\\', '/'); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(file, zos); + zos.closeEntry(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } }