Fix MoO2 subdirectory bug + remove sockdrive references
Build & Deploy / build-and-deploy (push) Successful in 36s
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:
@@ -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; }
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user