Fix MoO2 subdirectory bug + remove sockdrive references
Build & Deploy / build-and-deploy (push) Successful in 36s

- Fix flattenSingleDir: flatten when exactly 1 root directory exists,
  even if other metadata files (file_id.diz, *.nfo) are present at root
- Fix buildDosboxConf/buildJsdosJson: add 'cd <dir>' before executable
  when it lives in a subdirectory, since DOS treats '/' as switch char
  (previously 'mastori2/ORION2.EXE' was parsed as command 'mastori2'
  with switch '/ORION2.EXE')
- Remove SockdriveResource.java entirely
- Remove bundleType field from Game.java (was 'standard'/'sockdrive')
- Simplify GameResource.java download endpoint
- Clean up upload resource
This commit is contained in:
Hermes Agent
2026-05-28 12:10:43 +02:00
parent ba34065278
commit efa0aa5f81
5 changed files with 85 additions and 166 deletions
-4
View File
@@ -19,7 +19,6 @@ public class Game {
private Double rating;
private List<String> platforms;
private String bundleType; // "standard" or "sockdrive"
private String bundleFile; // relative path within game dir
private String setupExe; // relative path to SETUP.EXE (null if none)
@@ -77,9 +76,6 @@ public class Game {
public List<String> getPlatforms() { return platforms; }
public void setPlatforms(List<String> platforms) { this.platforms = platforms; }
public String getBundleType() { return bundleType; }
public void setBundleType(String bundleType) { this.bundleType = bundleType; }
public String getBundleFile() { return bundleFile; }
public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; }
+7 -37
View File
@@ -8,8 +8,6 @@ import jakarta.ws.rs.core.StreamingOutput;
import java.nio.file.NoSuchFileException;
import java.nio.file.Files;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Path("/api/games")
@Produces(MediaType.APPLICATION_JSON)
@@ -101,42 +99,14 @@ public class GameResource {
try {
var game = svc.load(id);
String filename = (game.getTitle() != null ? game.getTitle() : id) + ".zip";
if ("sockdrive".equals(game.getBundleType())) {
// ZIP all game files on the fly, excluding metadata
StreamingOutput stream = output -> {
var gameDir = svc.gameDir(id);
try (var zos = new ZipOutputStream(output)) {
Files.walk(gameDir)
.filter(Files::isRegularFile)
.filter(p -> !p.getFileName().toString().equals("game.json"))
.filter(p -> !p.getFileName().toString().startsWith("cover."))
.forEach(p -> {
try {
String entryName = gameDir.relativize(p).toString().replace('\\', '/');
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(p, zos);
zos.closeEntry();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
};
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
} else {
// Standard .jsdos bundle
var bundlePath = svc.getGamesDir().resolve(game.getBundleFile());
if (!Files.exists(bundlePath)) {
return Response.status(404).entity(Map.of("error", "Bundle file not found")).build();
}
StreamingOutput stream = output -> Files.copy(bundlePath, output);
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
var bundlePath = svc.getGamesDir().resolve(game.getBundleFile());
if (!Files.exists(bundlePath)) {
return Response.status(404).entity(Map.of("error", "Bundle file not found")).build();
}
StreamingOutput stream = output -> Files.copy(bundlePath, output);
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (Exception e) {
@@ -1,77 +0,0 @@
package com.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import java.io.*;
import java.nio.file.*;
import java.util.*;
/** Serves game files for sockdrive streaming mode. */
@jakarta.ws.rs.Path("/api/sockdrive")
@Produces(MediaType.APPLICATION_JSON)
public class SockdriveResource {
@Inject
GameService svc;
@GET
@jakarta.ws.rs.Path("/{slug}/files")
public Response listFiles(@PathParam("slug") String slug) {
Path gameDir = svc.gameDir(slug);
if (!Files.isDirectory(gameDir)) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
}
try {
var files = new ArrayList<Map<String, Object>>();
Files.walk(gameDir).filter(Files::isRegularFile).forEach(f -> {
String rel = gameDir.relativize(f).toString().replace('\\', '/');
try {
files.add(Map.of("path", rel, "size", Files.size(f)));
} catch (IOException ignored) {}
});
return Response.ok(Map.of("files", files, "total", files.size())).build();
} catch (IOException e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@GET
@jakarta.ws.rs.Path("/{slug}/file")
public Response getFile(
@PathParam("slug") String slug,
@QueryParam("path") String path
) {
if (path == null || path.isBlank()) {
return Response.status(400).entity(Map.of("error", "path parameter required")).build();
}
Path resolved = svc.gameDir(slug).resolve(path).normalize();
if (!resolved.startsWith(svc.gameDir(slug).normalize())) {
return Response.status(403).build();
}
if (!Files.exists(resolved) || Files.isDirectory(resolved)) {
return Response.status(404).build();
}
// Determine content type
String contentType = MediaType.APPLICATION_OCTET_STREAM;
String name = resolved.getFileName().toString().toLowerCase();
if (name.endsWith(".conf") || name.endsWith(".txt")) contentType = "text/plain";
else if (name.endsWith(".json")) contentType = "application/json";
else if (name.endsWith(".html")) contentType = "text/html";
else if (name.endsWith(".js")) contentType = "application/javascript";
final String ct = contentType;
StreamingOutput stream = output -> Files.copy(resolved, output);
return Response.ok(stream).type(ct).header("Accept-Ranges", "bytes").build();
}
}
+78 -47
View File
@@ -93,7 +93,6 @@ public class UploadResource {
// Save metadata
Game game = new Game(gameId, title);
game.setBundleType("standard");
game.setBundleFile(bundleFile);
game.setSetupExe(setupExe != null ? extractDir.relativize(Path.of(setupExe)).toString() : null);
game.setReady(true);
@@ -168,24 +167,29 @@ public class UploadResource {
/**
* If the extraction directory contains a single subdirectory, move
* its contents up (flatten). This handles ZIPs where all game files
* are inside a folder (e.g. "game/DOOM2.EXE" → "DOOM2.EXE").
* are inside a folder (e.g. "mastori2/ORION2.EXE" → "ORION2.EXE").
* Works even when there are metadata files (file_id.diz, *.nfo, etc.)
* alongside the single game directory at the ZIP root.
*/
private void flattenSingleDir(Path dir) throws IOException {
List<Path> entries;
Path singleDir = null;
try (var files = Files.list(dir)) {
entries = files.toList();
for (Path entry : files.toList()) {
if (Files.isDirectory(entry)) {
if (singleDir != null) return; // multiple directories — abort
singleDir = entry;
}
}
}
if (entries.size() != 1) return;
Path only = entries.get(0);
if (!Files.isDirectory(only)) return;
if (singleDir == null) return;
LOG.info("Flattening single root directory: " + only.getFileName());
LOG.info("Flattening root directory: " + singleDir.getFileName());
// Walk in reverse order so directories are empty when we delete them
try (var walk = Files.walk(only)) {
try (var walk = Files.walk(singleDir)) {
var list = walk.sorted(Comparator.reverseOrder()).toList();
for (Path f : list) {
if (f.equals(only)) continue;
Path target = dir.resolve(only.relativize(f));
if (f.equals(singleDir)) continue;
Path target = dir.resolve(singleDir.relativize(f));
if (Files.isDirectory(f)) {
Files.createDirectories(target);
Files.delete(f);
@@ -195,8 +199,8 @@ public class UploadResource {
}
}
}
Files.delete(only);
LOG.info("Flattened " + only.getFileName());
Files.delete(singleDir);
LOG.info("Flattened " + singleDir.getFileName());
}
// ─── Executable Detection ────────────────────────────────────
@@ -299,13 +303,31 @@ public class UploadResource {
});
}
// ─── Configuration Helpers ──────────────────────────────────
// ─── Configuration Helpers ──────────────────────────────────
/**
* Split a relative executable path into its directory and file parts.
* If relExe contains a '/' (or '\'), returns {dir, file}.
* If relExe is just a filename, returns {null, relExe}.
*/
private static String[] splitDirExe(String relExe) {
int idx = relExe.replace('\\', '/').lastIndexOf('/');
if (idx >= 0) {
return new String[]{relExe.substring(0, idx), relExe.substring(idx + 1)};
}
return new String[]{null, relExe};
}
private byte[] buildJsdosJson(String relExe) {
// relExe is always just a filename after flattenSingleDir
// DOS uses \ as path separator; / is a switch character.
// If the executable is in a subdirectory, cd into it first.
var parts = splitDirExe(relExe);
String script = parts[0] != null
? "cd " + parts[0] + "\n" + parts[1]
: relExe;
String json = "{"
+ "\"autoexec\":{"
+ "\"options\":{\"script\":{\"value\":\"" + escapeJson(relExe) + "\"}}"
+ "\"options\":{\"script\":{\"value\":\"" + escapeJson(script) + "\"}}"
+ "},"
+ "\"output\":{"
+ "\"options\":{\"autolock\":{\"value\":true}}}"
@@ -314,37 +336,46 @@ public class UploadResource {
}
private String buildDosboxConf(String relExe) {
// relExe is always just a filename after flattenSingleDir
return "[sdl]\n"
+ "autolock=true\n"
+ "usescancodes=true\n"
+ "output=surface\n"
+ "\n"
+ "[cpu]\n"
+ "core=auto\n"
+ "cycles=auto\n"
+ "\n"
+ "[mixer]\n"
+ "nosound=false\n"
+ "rate=44100\n"
+ "\n"
+ "[sblaster]\n"
+ "sbtype=sb16\n"
+ "irq=7\n"
+ "dma=1\n"
+ "hdma=5\n"
+ "\n"
+ "[dos]\n"
+ "xms=true\n"
+ "ems=true\n"
+ "umb=true\n"
+ "\n"
+ "[autoexec]\n"
+ "@echo off\n"
+ "mount c .\n"
+ "c:\n"
+ "cls\n"
+ relExe + "\n";
// DOS uses \ as path separator; / is a switch character.
// If the executable is in a subdirectory, cd into it first.
var parts = splitDirExe(relExe);
String dir = parts[0];
String exe = parts[1];
StringBuilder sb = new StringBuilder();
sb.append("[sdl]\n");
sb.append("autolock=true\n");
sb.append("usescancodes=true\n");
sb.append("output=surface\n");
sb.append("\n");
sb.append("[cpu]\n");
sb.append("core=auto\n");
sb.append("cycles=auto\n");
sb.append("\n");
sb.append("[mixer]\n");
sb.append("nosound=false\n");
sb.append("rate=44100\n");
sb.append("\n");
sb.append("[sblaster]\n");
sb.append("sbtype=sb16\n");
sb.append("irq=7\n");
sb.append("dma=1\n");
sb.append("hdma=5\n");
sb.append("\n");
sb.append("[dos]\n");
sb.append("xms=true\n");
sb.append("ems=true\n");
sb.append("umb=true\n");
sb.append("\n");
sb.append("[autoexec]\n");
sb.append("@echo off\n");
sb.append("mount c .\n");
sb.append("c:\n");
sb.append("cls\n");
if (dir != null) {
sb.append("cd ").append(dir).append("\n");
}
sb.append(exe).append("\n");
return sb.toString();
}
private String escapeJson(String s) {