Server-side save states via js-dos fsChanges hooks
This commit is contained in:
@@ -10,6 +10,7 @@ import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
@@ -160,6 +161,68 @@ public class GameController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the saved js-dos FS-changes bundle (in-game saves) for a game.
|
||||
* 404 when the game or the save doesn't exist.
|
||||
*/
|
||||
@GET
|
||||
@Path("/{id}/saves")
|
||||
@Produces(MediaType.APPLICATION_OCTET_STREAM)
|
||||
public Response getSave(@PathParam("id") String id) {
|
||||
try {
|
||||
svc.load(id);
|
||||
byte[] data = svc.loadSave(id);
|
||||
if (data == null) {
|
||||
return Response.status(404).entity(Map.of("error", "No save found")).build();
|
||||
}
|
||||
return Response.ok(data).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the js-dos FS-changes bundle (in-game saves) for a game. Body = raw bundle bytes. */
|
||||
@PUT
|
||||
@Path("/{id}/saves")
|
||||
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
|
||||
public Response putSave(@PathParam("id") String id, byte[] data) {
|
||||
try {
|
||||
svc.load(id);
|
||||
if (data == null || data.length == 0) {
|
||||
return Response.status(400).entity(Map.of("error", "Empty save data")).build();
|
||||
}
|
||||
svc.saveChanges(id, data);
|
||||
return Response.ok(Map.of("status", "saved")).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the saved js-dos FS-changes for a game (fresh start on next launch). */
|
||||
@DELETE
|
||||
@Path("/{id}/saves")
|
||||
public Response deleteSave(@PathParam("id") String id) {
|
||||
try {
|
||||
svc.load(id);
|
||||
svc.deleteSave(id);
|
||||
return Response.ok(Map.of("status", "deleted")).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Response.status(400).entity(Map.of("error", e.getMessage())).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") String id) {
|
||||
|
||||
@@ -42,13 +42,16 @@ public class GameService implements GameStore {
|
||||
|
||||
Path gamesDir;
|
||||
|
||||
Path savesDir;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
gamesDir = Path.of(dataDir, "games");
|
||||
savesDir = Path.of(dataDir, "saves");
|
||||
try {
|
||||
Files.createDirectories(gamesDir);
|
||||
Files.createDirectories(Path.of(dataDir, "artwork"));
|
||||
Files.createDirectories(Path.of(dataDir, "saves"));
|
||||
Files.createDirectories(savesDir);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Cannot create data directories", e);
|
||||
}
|
||||
@@ -122,11 +125,47 @@ public class GameService implements GameStore {
|
||||
/** Delete a game and all its files. */
|
||||
public void delete(String id) throws IOException {
|
||||
FileUtils.deleteDirectory(gameDir(id));
|
||||
deleteSave(id);
|
||||
// Clean up old flat .jsdos locations (pre-game-dir layout — backward compat)
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
|
||||
}
|
||||
|
||||
/** Path of the js-dos FS-changes save file for a game (in-game saves). */
|
||||
public Path savePath(String id) {
|
||||
assertSafeId(id);
|
||||
return savesDir.resolve(id + ".fschanges.jsdos");
|
||||
}
|
||||
|
||||
/** Load the saved js-dos FS changes for a game, or null if none exist. */
|
||||
public byte[] loadSave(String id) throws IOException {
|
||||
Path path = savePath(id);
|
||||
if (!Files.exists(path)) {
|
||||
return null;
|
||||
}
|
||||
return Files.readAllBytes(path);
|
||||
}
|
||||
|
||||
/** Persist js-dos FS changes for a game (atomic write). */
|
||||
public void saveChanges(String id, byte[] data) throws IOException {
|
||||
Path path = savePath(id);
|
||||
Path tmp = path.resolveSibling(path.getFileName() + ".tmp");
|
||||
Files.write(tmp, data);
|
||||
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
|
||||
/** Delete saved js-dos FS changes for a game. Returns true if a save existed. */
|
||||
public boolean deleteSave(String id) throws IOException {
|
||||
return Files.deleteIfExists(savePath(id));
|
||||
}
|
||||
|
||||
/** Reject ids that could escape the saves directory. */
|
||||
private static void assertSafeId(String id) {
|
||||
if (id == null || id.contains("/") || id.contains("\\") || id.contains("..")) {
|
||||
throw new IllegalArgumentException("Invalid game id: " + id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sanitize a string for use as a game directory ID. */
|
||||
public static String sanitizeId(String s) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
@@ -241,6 +241,86 @@ class GameServiceTest {
|
||||
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
|
||||
}
|
||||
|
||||
// -- js-dos FS-changes saves tests --
|
||||
|
||||
@Test
|
||||
void saveChanges_andLoadSave_roundTrip(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("game1", "Game One"));
|
||||
byte[] data = "PK\u0003\u0004fake-bundle".getBytes();
|
||||
svc.saveChanges("game1", data);
|
||||
|
||||
assertArrayEquals(data, svc.loadSave("game1"));
|
||||
assertEquals(
|
||||
tempDir.resolve("saves/game1.fschanges.jsdos"),
|
||||
svc.savePath("game1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadSave_noSave_returnsNull(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertNull(svc.loadSave("game1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveChanges_overwritesPreviousWithoutLeavingTmp(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.saveChanges("game1", "first".getBytes());
|
||||
svc.saveChanges("game1", "second".getBytes());
|
||||
|
||||
assertArrayEquals("second".getBytes(), svc.loadSave("game1"));
|
||||
try (var stream = Files.list(tempDir.resolve("saves"))) {
|
||||
assertEquals(1, stream.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSave_removesFileAndReportsExistence(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.saveChanges("game1", "data".getBytes());
|
||||
assertTrue(svc.deleteSave("game1"));
|
||||
assertNull(svc.loadSave("game1"));
|
||||
assertFalse(svc.deleteSave("game1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savePath_rejectsPathTraversal(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath("../evil"));
|
||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath("a/b"));
|
||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath("a\\b"));
|
||||
assertThrows(IllegalArgumentException.class, () -> svc.savePath(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_removesSaveFile(@TempDir Path tempDir) throws Exception {
|
||||
GameService svc = new GameService();
|
||||
svc.dataDir = tempDir.toString();
|
||||
svc.init();
|
||||
|
||||
svc.save(new Game("game1", "Game One"));
|
||||
svc.saveChanges("game1", "data".getBytes());
|
||||
|
||||
svc.delete("game1");
|
||||
|
||||
assertFalse(Files.exists(tempDir.resolve("saves/game1.fschanges.jsdos")));
|
||||
}
|
||||
|
||||
// -- JSON serialization tests --
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user