fix: address all code quality issues across 8 files
Build & Deploy / build-and-deploy (push) Failing after 45s

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
This commit is contained in:
David Alvarez
2026-06-10 09:36:22 +02:00
parent dfb46ef032
commit a6103902cc
8 changed files with 170 additions and 347 deletions
+7 -12
View File
@@ -188,7 +188,7 @@ public class ConfigBuilder {
return "path=c:\\\ncd " + dir + "\n" + exe + "\n"; return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
} }
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) { private static String buildCdMountScript(List<String> cdImages) {
StringBuilder script = new StringBuilder(); StringBuilder script = new StringBuilder();
script.append("mount c .\n"); script.append("mount c .\n");
char driveLetter = 'D'; char driveLetter = 'D';
@@ -200,6 +200,11 @@ public class ConfigBuilder {
.append(img).append("\"").append(flags).append("\n"); .append(img).append("\"").append(flags).append("\n");
driveLetter++; driveLetter++;
} }
return script.toString();
}
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
script.append("c:\n"); script.append("c:\n");
if (dir != null) { if (dir != null) {
script.append("path=c:\\\n"); script.append("path=c:\\\n");
@@ -210,17 +215,7 @@ public class ConfigBuilder {
} }
private static String buildCdOnlyAutoexecScript(List<String> cdImages) { private static String buildCdOnlyAutoexecScript(List<String> cdImages) {
StringBuilder script = new StringBuilder(); StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
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++;
}
script.append("c:\n"); script.append("c:\n");
return script.toString(); return script.toString();
} }
@@ -1,5 +1,6 @@
package com.dostalgia; package com.dostalgia;
import jakarta.annotation.Nonnull;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -40,7 +41,7 @@ public class ExecutableDetector {
List<String> result = new ArrayList<>(); List<String> result = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() { Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override @Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) { public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase(); String name = f.getFileName().toString().toLowerCase();
if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) { if (name.endsWith(".exe") || name.endsWith(".com") || name.endsWith(".bat")) {
result.add(dir.relativize(f).toString().replace('\\', '/')); result.add(dir.relativize(f).toString().replace('\\', '/'));
@@ -59,7 +60,7 @@ public class ExecutableDetector {
Files.walkFileTree(dir, new SimpleFileVisitor<>() { Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override @Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) { public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase(); String name = f.getFileName().toString().toLowerCase();
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE; return FileVisitResult.CONTINUE;
@@ -103,7 +104,7 @@ public class ExecutableDetector {
Files.walkFileTree(dir, new SimpleFileVisitor<>() { Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override @Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) { public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase(); String name = f.getFileName().toString().toLowerCase();
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name; String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
+5 -12
View File
@@ -23,6 +23,8 @@ import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.logging.Logger;
import jakarta.annotation.Nonnull;
/** Manages game metadata stored as JSON files on disk. */ /** Manages game metadata stored as JSON files on disk. */
@ApplicationScoped @ApplicationScoped
@@ -125,12 +127,12 @@ public class GameService implements GameStore {
if (Files.exists(dir)) { if (Files.exists(dir)) {
Files.walkFileTree(dir, new SimpleFileVisitor<>() { Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override @Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException { public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException {
Files.delete(f); Files.delete(f);
return FileVisitResult.CONTINUE; return FileVisitResult.CONTINUE;
} }
@Override @Override
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException { public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException {
Files.delete(d); Files.delete(d);
return FileVisitResult.CONTINUE; return FileVisitResult.CONTINUE;
} }
@@ -227,16 +229,7 @@ public class GameService implements GameStore {
if (entry.getName().equals(relExe.replace('\\', '/'))) { if (entry.getName().equals(relExe.replace('\\', '/'))) {
byte[] buf = new byte[0x80]; byte[] buf = new byte[0x80];
int read = zis.readNBytes(buf, 0, buf.length); int read = zis.readNBytes(buf, 0, buf.length);
if (read < 2) return null; return PlatformDetector.detectFromBytes(buf, read);
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";
} }
zis.closeEntry(); zis.closeEntry();
} }
+76 -228
View File
@@ -12,12 +12,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.*;
import java.util.Calendar;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.logging.Logger; import java.util.logging.Logger;
/** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */ /** 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)) { if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
return accessToken; return accessToken;
} }
String body = "client_id=" + clientId.get() String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
+ "&client_secret=" + clientSecret.get() String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured"));
String body = "client_id=" + cid
+ "&client_secret=" + secret
+ "&grant_type=client_credentials"; + "&grant_type=client_credentials";
HttpRequest req = HttpRequest.newBuilder() HttpRequest req = HttpRequest.newBuilder()
@@ -72,27 +69,36 @@ public class IgdbService {
JsonNode json = mapper.readTree(res.body()); JsonNode json = mapper.readTree(res.body());
accessToken = json.get("access_token").asText(); accessToken = json.get("access_token").asText();
int expiresIn = json.get("expires_in").asInt(); 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)"); LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
return accessToken; return accessToken;
} }
private HttpRequest.Builder igdbRequest(String path) throws Exception { private HttpRequest.Builder igdbRequest(String path) throws Exception {
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
return HttpRequest.newBuilder() return HttpRequest.newBuilder()
.uri(URI.create(IGDB_API + path)) .uri(URI.create(IGDB_API + path))
.header("Client-ID", clientId.get()) .header("Client-ID", cid)
.header("Authorization", "Bearer " + getToken()) .header("Authorization", "Bearer " + getToken())
.header("Content-Type", "text/plain"); .header("Content-Type", "text/plain");
} }
/** /** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */
* Search IGDB for games matching the given query. private JsonNode postAndGet(String endpoint, String apql) throws Exception {
* Returns a list of simplified result maps suitable for JSON serialization. HttpRequest req = igdbRequest(endpoint)
*/ .POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> 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<Map<String, Object>> search(String query) throws Exception { public List<Map<String, Object>> search(String query) throws Exception {
if (!isConfigured()) return List.of(); if (!isConfigured()) return List.of();
// Search limited to DOS platform (IGDB platform ID 13)
String apql = "search \"" + sanitize(query) + "\";" String apql = "search \"" + sanitize(query) + "\";"
+ " fields name,first_release_date,genres.name," + " fields name,first_release_date,genres.name,"
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher," + " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
@@ -100,19 +106,9 @@ public class IgdbService {
+ " where platforms = [13];" + " where platforms = [13];"
+ " limit 20;"; + " limit 20;";
HttpRequest req = igdbRequest("/games") JsonNode results = postAndGet("/games", apql);
.POST(HttpRequest.BodyPublishers.ofString(apql)) if (results == null || results.isEmpty()) {
.build(); LOG.info("IGDB search: no results for '" + query + "'");
HttpResponse<String> 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());
return List.of(); return List.of();
} }
@@ -122,12 +118,7 @@ public class IgdbService {
entry.put("igdb_id", game.get("id").asInt()); entry.put("igdb_id", game.get("id").asInt());
entry.put("name", game.has("name") ? game.get("name").asText() : query); entry.put("name", game.has("name") ? game.get("name").asText() : query);
if (game.has("first_release_date")) { epochToYear(game, "first_release_date").ifPresent(y -> entry.put("year", y));
long epoch = game.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
entry.put("year", cal.get(Calendar.YEAR));
}
if (game.has("genres") && game.get("genres").isArray()) { if (game.has("genres") && game.get("genres").isArray()) {
List<String> genres = new ArrayList<>(); List<String> genres = new ArrayList<>();
@@ -137,7 +128,6 @@ public class IgdbService {
entry.put("genres", genres); entry.put("genres", genres);
} }
// Extract developer and publisher
if (game.has("involved_companies") && game.get("involved_companies").isArray()) { if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
String developer = null; String developer = null;
String publisher = null; String publisher = null;
@@ -158,216 +148,106 @@ public class IgdbService {
entry.put("summary", game.get("summary").asText()); entry.put("summary", game.get("summary").asText());
} }
// Platforms (check if DOS or PC)
if (game.has("platforms") && game.get("platforms").isArray()) { if (game.has("platforms") && game.get("platforms").isArray()) {
List<Integer> platformIds = new ArrayList<>(); List<Integer> platformIds = new ArrayList<>();
for (JsonNode p : game.get("platforms")) { for (JsonNode p : game.get("platforms")) {
platformIds.add(p.asInt()); platformIds.add(p.asInt());
} }
entry.put("platform_ids", platformIds); entry.put("platform_ids", platformIds);
boolean isDos = platformIds.contains(13); // 13 = DOS entry.put("is_dos", platformIds.contains(13));
entry.put("is_dos", isDos);
} }
// 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")) { if (game.has("cover") && game.get("cover").has("url")) {
String thumbUrl = game.get("cover").get("url").asText(); 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); String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
entry.put("cover_url", coverUrl); entry.put("cover_url", coverUrl);
} else if (game.has("cover") && game.get("cover").isInt()) { } 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(); int coverId = game.get("cover").asInt();
String coverUrl = fetchCoverUrl(coverId); String coverUrl = fetchCoverUrl(coverId);
if (coverUrl != null) { if (coverUrl != null) entry.put("cover_url", coverUrl);
entry.put("cover_url", coverUrl);
}
} }
out.add(entry); out.add(entry);
} }
return out; return out;
} }
/**
* Fetch the cover image URL for a given IGDB cover ID.
*/
private String fetchCoverUrl(int coverId) throws Exception { private String fetchCoverUrl(int coverId) throws Exception {
String apql = "fields url; where id = " + coverId + ";"; JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
HttpRequest req = igdbRequest("/covers") if (results == null || results.isEmpty()) return null;
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> 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;
String thumbUrl = results.get(0).get("url").asText(); 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); return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
} }
/** private List<String> fetchVideos(int igdbId) throws Exception {
* Fetch YouTube video IDs associated with an IGDB game. JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";");
*/ if (results == null) return List.of();
public List<String> 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<String> 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();
List<String> videos = new ArrayList<>(); List<String> videos = new ArrayList<>();
for (JsonNode v : results) { for (JsonNode v : results) {
if (v.has("video_id")) { if (v.has("video_id")) videos.add(v.get("video_id").asText());
videos.add(v.get("video_id").asText());
}
} }
return videos; return videos;
} }
/** private List<String> fetchScreenshots(int igdbId) throws Exception {
* Fetch screenshot image URLs for an IGDB game. JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";");
*/ if (results == null) return List.of();
public List<String> fetchScreenshots(int igdbId) throws Exception {
String apql = "fields url; where game = " + igdbId + ";";
HttpRequest req = igdbRequest("/screenshots")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> 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();
List<String> screenshots = new ArrayList<>(); List<String> screenshots = new ArrayList<>();
for (JsonNode s : results) { for (JsonNode s : results) {
if (s.has("url")) { if (s.has("url")) {
String thumbUrl = s.get("url").asText(); String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
// Use bigger size for display
String fullUrl = "https:" + thumbUrl.replace("t_thumb", "t_1080p");
screenshots.add(fullUrl); screenshots.add(fullUrl);
} }
} }
return screenshots; return screenshots;
} }
/** // ─── Auto-Scrape ──────────────────────────────────────────────
* 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.
*/
public void autoScrape(Game game) { public void autoScrape(Game game) {
if (!isConfigured()) return; if (!isConfigured()) return;
try { try {
List<Map<String, Object>> results = search(game.getTitle()); List<Map<String, Object>> results = search(game.getTitle());
if (results.isEmpty()) { if (results.isEmpty()) {
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'"); LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
return; return;
} }
// Pick best match: prefer DOS platform, otherwise first result
Map<String, Object> best = null; Map<String, Object> best = null;
for (Map<String, Object> r : results) { for (Map<String, Object> r : results) {
if (Boolean.TRUE.equals(r.get("is_dos"))) { if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; }
best = r;
break;
}
} }
if (best == null) best = results.getFirst(); if (best == null) best = results.getFirst();
applyMatch(game, best); applyMatch(game, best);
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'"); LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
// Fetch videos and screenshots
Object igdbIdObj = best.get("igdb_id"); Object igdbIdObj = best.get("igdb_id");
if (igdbIdObj instanceof Number igdbIdNum) { if (igdbIdObj instanceof Number igdbIdNum) {
int igdbId = igdbIdNum.intValue(); int igdbId = igdbIdNum.intValue();
try { try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
List<String> videos = fetchVideos(igdbId); catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); }
if (!videos.isEmpty()) { try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
game.setVideos(videos); catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); }
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch videos: " + e.getMessage());
}
try {
List<String> screenshots = fetchScreenshots(igdbId);
if (!screenshots.isEmpty()) {
game.setScreenshots(screenshots);
game.setHasScreenshots(true);
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage());
}
} }
} catch (Exception e) { } catch (Exception e) {
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage()); 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 { 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," String apql = "fields name,first_release_date,genres.name,"
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher," + " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
+ " summary,cover.url;" + " summary,cover.url;"
+ " where id = " + igdbId + ";"; + " where id = " + igdbId + ";";
JsonNode results = postAndGet("/games", apql);
HttpRequest req = igdbRequest("/games") if (results == null || results.isEmpty()) {
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> 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()) {
throw new RuntimeException("IGDB: no game found with ID " + igdbId); throw new RuntimeException("IGDB: no game found with ID " + igdbId);
} }
applyMatch(game, results.get(0)); applyMatch(game, results.get(0));
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
// Fetch videos and screenshots catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); }
try { try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
List<String> videos = fetchVideos(igdbId); catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); }
if (!videos.isEmpty()) {
game.setVideos(videos);
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage());
}
try {
List<String> 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());
}
} }
/**
* Apply IGDB game data to a local Game object. Downloads cover if available.
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void applyMatch(Game game, Object matchData) throws Exception { private void applyMatch(Game game, Object matchData) throws Exception {
Map<String, Object> data; Map<String, Object> data;
@@ -378,56 +258,26 @@ public class IgdbService {
} else { } else {
return; return;
} }
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank()))
// Update game fields
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) {
game.setTitle((String) data.get("name")); game.setTitle((String) data.get("name"));
} if (data.containsKey("year")) game.setYear((Integer) data.get("year"));
if (data.containsKey("year")) { if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer"));
game.setYear((Integer) data.get("year")); if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher"));
} if (data.containsKey("summary")) game.setDescription((String) data.get("summary"));
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")) { if (data.containsKey("genres")) {
List<String> genres = (List<String>) data.get("genres"); List<String> genres = (List<String>) data.get("genres");
if (!genres.isEmpty()) { if (!genres.isEmpty()) game.setGenre(genres.getFirst());
game.setGenre(genres.getFirst());
}
} }
if (data.containsKey("igdb_id")) { if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id"));
game.setIgdbId((Integer) data.get("igdb_id"));
}
// Download cover
String coverUrl = (String) data.get("cover_url"); String coverUrl = (String) data.get("cover_url");
if (coverUrl != null && !coverUrl.isBlank()) { if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl);
downloadCover(game, coverUrl);
}
} }
/**
* Download a cover image from IGDB CDN and save locally.
*/
private void downloadCover(Game game, String coverUrl) throws Exception { private void downloadCover(Game game, String coverUrl) throws Exception {
// Ensure full URL (IGDB sometimes omits https:)
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl; 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<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile( HttpResponse<Path> 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) { if (res.statusCode() == 200) {
game.setHasCover(true); game.setHasCover(true);
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'"); 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<Integer> 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) { private String sanitize(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\""); return s.replace("\\", "\\\\").replace("\"", "\\\"");
} }
@@ -444,14 +307,7 @@ public class IgdbService {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0); map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
map.put("name", node.has("name") ? node.get("name").asText() : ""); map.put("name", node.has("name") ? node.get("name").asText() : "");
epochToYear(node, "first_release_date").ifPresent(y -> map.put("year", y));
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));
}
if (node.has("genres") && node.get("genres").isArray()) { if (node.has("genres") && node.get("genres").isArray()) {
List<String> genres = new ArrayList<>(); List<String> genres = new ArrayList<>();
for (JsonNode g : node.get("genres")) { for (JsonNode g : node.get("genres")) {
@@ -459,7 +315,6 @@ public class IgdbService {
} }
map.put("genres", genres); map.put("genres", genres);
} }
if (node.has("involved_companies") && node.get("involved_companies").isArray()) { if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
for (JsonNode ic : node.get("involved_companies")) { for (JsonNode ic : node.get("involved_companies")) {
if (ic.has("company") && ic.get("company").has("name")) { 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());
if (node.has("summary")) {
map.put("summary", node.get("summary").asText());
}
// Fetch cover URL — handle cover.url object or raw ID
if (node.has("cover")) { if (node.has("cover")) {
JsonNode cover = node.get("cover"); JsonNode cover = node.get("cover");
try { try {
if (cover.has("url")) { if (cover.has("url")) {
String thumbUrl = cover.get("url").asText(); String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE);
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
map.put("cover_url", coverUrl); map.put("cover_url", coverUrl);
} else if (cover.isInt() || cover.isLong()) { } else if (cover.isInt() || cover.isLong()) {
String coverUrl = fetchCoverUrl(cover.asInt()); String coverUrl = fetchCoverUrl(cover.asInt());
@@ -493,7 +342,6 @@ public class IgdbService {
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
return map; return map;
} }
} }
@@ -31,37 +31,35 @@ public class PlatformDetector {
try (var fis = Files.newInputStream(exePath)) { try (var fis = Files.newInputStream(exePath)) {
byte[] buf = new byte[0x80]; byte[] buf = new byte[0x80];
int read = fis.readNBytes(buf, 0, buf.length); int read = fis.readNBytes(buf, 0, buf.length);
if (read < 2) return null; return detectFromBytes(buf, read);
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";
} catch (IOException e) { } catch (IOException e) {
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage()); LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
return null; 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";
}
+15 -24
View File
@@ -20,42 +20,33 @@ public class StaticResource {
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir; String dataDir;
@GET /** Serve a file from a subdirectory of the data dir with path traversal protection. */
@jakarta.ws.rs.Path("/games/{path: .*}") private Response serveFile(Path base, String path, String cacheHeader) {
public Response getGameFile(@PathParam("path") String path) { Path file = base.resolve(path).normalize();
Path file = Path.of(dataDir, "games", path).normalize();
Path base = Path.of(dataDir, "games").normalize();
if (!file.startsWith(base)) { if (!file.startsWith(base)) {
return Response.status(403).build(); return Response.status(403).build();
} }
if (!Files.exists(file) || Files.isDirectory(file)) { if (!Files.exists(file) || Files.isDirectory(file)) {
return Response.status(404).build(); return Response.status(404).build();
} }
String contentType = guessContentType(file.getFileName().toString()); String contentType = guessContentType(file.getFileName().toString());
return Response.ok(new FileStream(file)) var builder = Response.ok(new FileStream(file)).type(contentType);
.type(contentType) if (cacheHeader != null) {
.header("Accept-Ranges", "bytes") builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim());
.build(); }
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 @GET
@jakarta.ws.rs.Path("/artwork/{path: .*}") @jakarta.ws.rs.Path("/artwork/{path: .*}")
public Response getArtwork(@PathParam("path") String path) { public Response getArtwork(@PathParam("path") String path) {
Path file = Path.of(dataDir, "artwork", path).normalize(); return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
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();
} }
/** Health check */ /** Health check */
@@ -20,6 +20,7 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.logging.Logger; import java.util.logging.Logger;
import jakarta.annotation.Nonnull;
@jakarta.ws.rs.Path("/api/upload") @jakarta.ws.rs.Path("/api/upload")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -139,7 +140,8 @@ public class UploadResource {
zip.createBundle(extractDir, mainExe, cdImages, bundlePath); zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
// Detect platform (DOS vs Windows) // 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 // Save metadata
Game game = new Game(gameId, title); 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 // Store the selected executable and the full list for the UI picker
String relMain = mainExe != null String relMain = mainExe != null
? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/') ? extractDir.relativize(mainExePath).toString().replace('\\', '/')
: ""; : "";
game.setExecutable(relMain); game.setExecutable(relMain);
game.setExecutables(exeDetector.findAll(extractDir)); game.setExecutables(exeDetector.findAll(extractDir));
if (setupExe != null) { if (setupExe != null) {
String setupPlatform = platform.detect(Path.of(setupExe)); Path setupExePath = Path.of(setupExe);
String setupPlatform = platform.detect(setupExePath);
if (!"windows".equals(setupPlatform)) { if (!"windows".equals(setupPlatform)) {
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString()); game.setSetupExe(extractDir.relativize(setupExePath).toString());
} }
} }
game.setReady(true); game.setReady(true);
@@ -221,12 +224,12 @@ public class UploadResource {
if (!Files.exists(dir)) return; if (!Files.exists(dir)) return;
Files.walkFileTree(dir, new SimpleFileVisitor<>() { Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override @Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException { public FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) throws IOException {
Files.delete(f); Files.delete(f);
return FileVisitResult.CONTINUE; return FileVisitResult.CONTINUE;
} }
@Override @Override
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException { public FileVisitResult postVisitDirectory(@Nonnull Path d, IOException e) throws IOException {
Files.delete(d); Files.delete(d);
return FileVisitResult.CONTINUE; return FileVisitResult.CONTINUE;
} }
+27 -33
View File
@@ -13,6 +13,8 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/** /**
* ZIP extraction, directory flattening, and .jsdos bundle creation. * ZIP extraction, directory flattening, and .jsdos bundle creation.
@@ -25,7 +27,6 @@ public class ZipService {
@Inject @Inject
ConfigBuilder config; ConfigBuilder config;
/** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */
private static final Set<String> SKIP_EXT = Set.of( private static final Set<String> SKIP_EXT = Set.of(
".nrg", ".mdf", ".mds", ".sub", ".dmg" ".nrg", ".mdf", ".mds", ".sub", ".dmg"
); );
@@ -50,14 +51,13 @@ public class ZipService {
/** /**
* If the extraction directory contains a single subdirectory, move its contents up. * 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 { public void flattenSingleDir(Path dir) throws IOException {
Path singleDir = null; Path singleDir = null;
try (var files = Files.list(dir)) { try (var files = Files.list(dir)) {
for (Path entry : files.toList()) { for (Path entry : files.toList()) {
if (Files.isDirectory(entry)) { if (Files.isDirectory(entry)) {
if (singleDir != null) return; // multiple directories — abort if (singleDir != null) return;
singleDir = entry; singleDir = entry;
} }
} }
@@ -85,10 +85,8 @@ public class ZipService {
/** /**
* Create a .jsdos bundle ZIP from the extracted game directory. * 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<String> cdImages, Path bundlePath) throws IOException { public void createBundle(Path extractDir, String exePath, List<String> cdImages, Path bundlePath) throws IOException {
// Write .jsdos config into the extract directory
Path jsdos = extractDir.resolve(".jsdos"); Path jsdos = extractDir.resolve(".jsdos");
Files.createDirectories(jsdos); Files.createDirectories(jsdos);
if (exePath != null) { if (exePath != null) {
@@ -100,8 +98,7 @@ public class ZipService {
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages)); Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
} }
// ZIP it up from extractDir try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
// Phase 1: Collect all directory paths // Phase 1: Collect all directory paths
var allDirs = new TreeSet<String>(); var allDirs = new TreeSet<String>();
@@ -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) { for (String dir : allDirs) {
zos.putNextEntry(new java.util.zip.ZipEntry(dir)); zos.putNextEntry(new ZipEntry(dir));
zos.closeEntry(); 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)) { try (var walk = Files.walk(jsdos)) {
walk.filter(Files::isRegularFile).sorted().forEach(f -> { walk.filter(Files::isRegularFile).sorted()
try { .forEach(f -> zipEntry(zos, extractDir, f));
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);
}
});
} }
// Phase 4: Game files (excluding skipped extensions) // Phase 4: Game files (excluding skipped extensions)
@@ -146,22 +135,27 @@ public class ZipService {
return dot < 0 || !SKIP_EXT.contains(n.substring(dot)); return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
}) })
.sorted() .sorted()
.forEach(f -> { .forEach(f -> zipEntry(zos, extractDir, 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);
}
});
} }
} }
// Clean up .jsdos config files from the extract dir // Clean up .jsdos config files from the extract dir
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> { try (var cleanup = Files.walk(jsdos)) {
try { Files.deleteIfExists(p); } catch (IOException ignored) {} 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);
}
} }
} }