Migrate backend from Go to Quarkus (Java 21 / JAX-RS)
- Replaced Go stdlib server with Quarkus (RESTEasy Reactive + Jackson) - 7 Java classes: Game POJO, GameService (JSON file I/O), 4 REST resources, static file serving - Same JSON-per-game storage, same ZIP upload + bundle creation - Same Svelte frontend (unchanged), built into the JAR via maven-resources-plugin - Multi-stage Dockerfile: Node → Maven → JRE runtime
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Represents a single DOS game, stored as data/games/{id}/game.json */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class Game {
|
||||
private String id;
|
||||
private String title;
|
||||
private Integer year;
|
||||
private String genre;
|
||||
private String developer;
|
||||
private String publisher;
|
||||
private String description;
|
||||
private Double rating;
|
||||
private List<String> platforms;
|
||||
|
||||
private String bundleType; // "standard" or "sockdrive"
|
||||
private String bundleFile; // relative path within game dir
|
||||
|
||||
private Integer igdbId;
|
||||
private String igdbCoverId;
|
||||
|
||||
private boolean hasCover;
|
||||
private boolean hasScreenshots;
|
||||
private List<String> screenshots;
|
||||
|
||||
private boolean ready;
|
||||
private Instant createdAt;
|
||||
private Instant updatedAt;
|
||||
|
||||
// ─── Constructors ────────────────────────────────────────────
|
||||
|
||||
public Game() {}
|
||||
|
||||
public Game(String id, String title) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.ready = false;
|
||||
this.createdAt = Instant.now();
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// ─── Getters & Setters ──────────────────────────────────────
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public Integer getYear() { return year; }
|
||||
public void setYear(Integer year) { this.year = year; }
|
||||
|
||||
public String getGenre() { return genre; }
|
||||
public void setGenre(String genre) { this.genre = genre; }
|
||||
|
||||
public String getDeveloper() { return developer; }
|
||||
public void setDeveloper(String developer) { this.developer = developer; }
|
||||
|
||||
public String getPublisher() { return publisher; }
|
||||
public void setPublisher(String publisher) { this.publisher = publisher; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public Double getRating() { return rating; }
|
||||
public void setRating(Double rating) { this.rating = rating; }
|
||||
|
||||
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; }
|
||||
|
||||
public Integer getIgdbId() { return igdbId; }
|
||||
public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; }
|
||||
|
||||
public String getIgdbCoverId() { return igdbCoverId; }
|
||||
public void setIgdbCoverId(String igdbCoverId) { this.igdbCoverId = igdbCoverId; }
|
||||
|
||||
public boolean isHasCover() { return hasCover; }
|
||||
public void setHasCover(boolean hasCover) { this.hasCover = hasCover; }
|
||||
|
||||
public boolean isHasScreenshots() { return hasScreenshots; }
|
||||
public void setHasScreenshots(boolean hasScreenshots) { this.hasScreenshots = hasScreenshots; }
|
||||
|
||||
public List<String> getScreenshots() { return screenshots; }
|
||||
public void setScreenshots(List<String> screenshots) { this.screenshots = screenshots; }
|
||||
|
||||
public boolean isReady() { return ready; }
|
||||
public void setReady(boolean ready) { this.ready = ready; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.*;
|
||||
|
||||
@Path("/api/games")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class GameResource {
|
||||
|
||||
@Inject
|
||||
GameService svc;
|
||||
|
||||
@GET
|
||||
public Response list(
|
||||
@QueryParam("search") String search,
|
||||
@QueryParam("genre") String genre,
|
||||
@QueryParam("year") Integer year,
|
||||
@QueryParam("limit") @DefaultValue("50") int limit,
|
||||
@QueryParam("offset") @DefaultValue("0") int offset
|
||||
) {
|
||||
try {
|
||||
var all = svc.list();
|
||||
|
||||
// Client-side filtering (small dataset, fine for a personal library)
|
||||
if (search != null && !search.isBlank()) {
|
||||
all = all.stream()
|
||||
.filter(g -> g.getTitle().toLowerCase().contains(search.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
if (genre != null && !genre.isBlank()) {
|
||||
all = all.stream()
|
||||
.filter(g -> genre.equalsIgnoreCase(g.getGenre()))
|
||||
.toList();
|
||||
}
|
||||
if (year != null) {
|
||||
all = all.stream()
|
||||
.filter(g -> year.equals(g.getYear()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
int total = all.size();
|
||||
int from = Math.min(offset, total);
|
||||
int to = Math.min(offset + limit, total);
|
||||
var page = all.subList(from, to);
|
||||
|
||||
return Response.ok(Map.of("games", page, "total", total)).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public Response get(@PathParam("id") String id) {
|
||||
try {
|
||||
var game = svc.load(id);
|
||||
return Response.ok(game).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public Response update(@PathParam("id") String id, Map<String, Object> updates) {
|
||||
try {
|
||||
var game = svc.load(id);
|
||||
|
||||
if (updates.containsKey("title")) game.setTitle((String) updates.get("title"));
|
||||
if (updates.containsKey("year")) game.setYear(asInt(updates.get("year")));
|
||||
if (updates.containsKey("genre")) game.setGenre((String) updates.get("genre"));
|
||||
if (updates.containsKey("developer")) game.setDeveloper((String) updates.get("developer"));
|
||||
if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher"));
|
||||
if (updates.containsKey("description")) game.setDescription((String) updates.get("description"));
|
||||
if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating")));
|
||||
|
||||
svc.save(game);
|
||||
return Response.ok(game).build();
|
||||
} catch (NoSuchFileException e) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
public Response delete(@PathParam("id") String id) {
|
||||
try {
|
||||
svc.delete(id);
|
||||
return Response.ok(Map.of("status", "deleted")).build();
|
||||
} catch (Exception e) {
|
||||
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
private Integer asInt(Object v) {
|
||||
if (v instanceof Number n) return n.intValue();
|
||||
return null;
|
||||
}
|
||||
|
||||
private Double asDouble(Object v) {
|
||||
if (v instanceof Number n) return n.doubleValue();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/** Manages game metadata stored as JSON files on disk. */
|
||||
@ApplicationScoped
|
||||
public class GameService {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
Path gamesDir;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
gamesDir = Path.of(dataDir, "games");
|
||||
try {
|
||||
Files.createDirectories(gamesDir);
|
||||
Files.createDirectories(Path.of(dataDir, "artwork"));
|
||||
Files.createDirectories(Path.of(dataDir, "saves"));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Cannot create data directories", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Game Directory Helpers ──────────────────────────────────
|
||||
|
||||
Path gameDir(String id) {
|
||||
return gamesDir.resolve(id);
|
||||
}
|
||||
|
||||
Path gameJsonPath(String id) {
|
||||
return gameDir(id).resolve("game.json");
|
||||
}
|
||||
|
||||
// ─── CRUD ────────────────────────────────────────────────────
|
||||
|
||||
/** Load a game by ID. Throws if not found. */
|
||||
public Game load(String id) throws IOException {
|
||||
Path path = gameJsonPath(id);
|
||||
if (!Files.exists(path)) {
|
||||
throw new NoSuchFileException("game.json not found for: " + id);
|
||||
}
|
||||
Game game = mapper.readValue(path.toFile(), Game.class);
|
||||
// Detect cover file
|
||||
game.setHasCover(
|
||||
Files.exists(gameDir(id).resolve("cover.jpg")) ||
|
||||
Files.exists(gameDir(id).resolve("cover.png")) ||
|
||||
Files.exists(gameDir(id).resolve("cover.webp"))
|
||||
);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Save game metadata to disk. */
|
||||
public void save(Game game) throws IOException {
|
||||
game.setUpdatedAt(Instant.now());
|
||||
if (game.getCreatedAt() == null) {
|
||||
game.setCreatedAt(Instant.now());
|
||||
}
|
||||
Files.createDirectories(gameDir(game.getId()));
|
||||
mapper.writeValue(gameJsonPath(game.getId()).toFile(), game);
|
||||
}
|
||||
|
||||
/** List all games, sorted by title. */
|
||||
public List<Game> list() throws IOException {
|
||||
List<Game> games = new ArrayList<>();
|
||||
if (!Files.isDirectory(gamesDir)) return games;
|
||||
|
||||
try (var stream = Files.list(gamesDir)) {
|
||||
for (Path dir : (Iterable<Path>) stream::iterator) {
|
||||
if (!Files.isDirectory(dir)) continue;
|
||||
try {
|
||||
Game g = load(dir.getFileName().toString());
|
||||
games.add(g);
|
||||
} catch (Exception ignored) {
|
||||
// skip broken entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
games.sort(Comparator.comparing(Game::getTitle));
|
||||
return games;
|
||||
}
|
||||
|
||||
/** Delete a game and all its files. */
|
||||
public boolean delete(String id) throws IOException {
|
||||
// Remove game directory
|
||||
Path dir = gameDir(id);
|
||||
if (Files.exists(dir)) {
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Remove flat .jsdos bundle
|
||||
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────
|
||||
|
||||
/** Sanitize a string for use as a game directory ID. */
|
||||
public static String sanitizeId(String s) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (char c : s.toCharArray()) {
|
||||
if (c >= 'a' && c <= 'z') sb.append(c);
|
||||
else if (c >= 'A' && c <= 'Z') sb.append((char)(c + 32));
|
||||
else if (c >= '0' && c <= '9') sb.append(c);
|
||||
else if (c == ' ' || c == '-' || c == '_') sb.append('-');
|
||||
}
|
||||
String result = sb.toString().replaceAll("^-+|-+$", "");
|
||||
return result.isEmpty() ? "unknown" : result;
|
||||
}
|
||||
|
||||
/** Check if a file exists. */
|
||||
public static boolean exists(Path p) {
|
||||
return Files.exists(p);
|
||||
}
|
||||
|
||||
public Path getGamesDir() { return gamesDir; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.Map;
|
||||
|
||||
/** IGDB integration placeholder. Enable by setting TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET. */
|
||||
@Path("/api/igdb")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class IgdbResource {
|
||||
|
||||
@GET
|
||||
@Path("/search")
|
||||
public Response search(@QueryParam("q") @DefaultValue("") String query) {
|
||||
if (query.isBlank()) {
|
||||
return Response.status(400).entity(Map.of("error", "Query parameter 'q' required")).build();
|
||||
}
|
||||
// TODO: Implement real IGDB search via Twitch OAuth2 + IGDB API
|
||||
// 1. POST https://id.twitch.tv/oauth2/token → access_token
|
||||
// 2. POST https://api.igdb.com/v4/games with search body
|
||||
// 3. Return matches with cover URLs
|
||||
return Response.ok(java.util.List.of()).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/covers/{igdbId}")
|
||||
public Response covers(@PathParam("igdbId") int igdbId) {
|
||||
return Response.ok(java.util.List.of()).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
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. */
|
||||
@Path("/api/sockdrive")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class SockdriveResource {
|
||||
|
||||
@Inject
|
||||
GameService svc;
|
||||
|
||||
@GET
|
||||
@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
|
||||
@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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.StreamingOutput;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.Map;
|
||||
|
||||
/** Serves game bundles (.jsdos) and artwork from the external data directory. */
|
||||
@Path("/")
|
||||
public class StaticResource {
|
||||
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
@GET
|
||||
@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();
|
||||
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();
|
||||
}
|
||||
|
||||
@GET
|
||||
@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();
|
||||
}
|
||||
|
||||
/** Health check */
|
||||
@GET
|
||||
@Path("/api/health")
|
||||
public Response health() {
|
||||
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
||||
}
|
||||
|
||||
private String guessContentType(String name) {
|
||||
String n = name.toLowerCase();
|
||||
if (n.endsWith(".jsdos")) return "application/zip";
|
||||
if (n.endsWith(".zip")) return "application/zip";
|
||||
if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (n.endsWith(".png")) return "image/png";
|
||||
if (n.endsWith(".webp")) return "image/webp";
|
||||
if (n.endsWith(".gif")) return "image/gif";
|
||||
if (n.endsWith(".conf") || n.endsWith(".txt")) return "text/plain";
|
||||
if (n.endsWith(".json")) return "application/json";
|
||||
if (n.endsWith(".html")) return "text/html";
|
||||
if (n.endsWith(".js")) return "application/javascript";
|
||||
if (n.endsWith(".css")) return "text/css";
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
|
||||
/** Streaming file output that avoids loading the whole file into memory. */
|
||||
private record FileStream(Path file) implements StreamingOutput {
|
||||
@Override
|
||||
public void write(OutputStream output) throws IOException {
|
||||
Files.copy(file, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.jboss.resteasy.reactive.RestForm;
|
||||
import org.jboss.resteasy.reactive.multipart.FileUpload;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
|
||||
@Path("/api/upload")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class UploadResource {
|
||||
|
||||
private static final double SOCKDRIVE_THRESHOLD_MB = 80.0;
|
||||
|
||||
@Inject
|
||||
GameService svc;
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||
public Response upload(
|
||||
@RestForm("file") FileUpload upload,
|
||||
@RestForm("title") String title
|
||||
) throws Exception {
|
||||
if (upload == null) {
|
||||
return Response.status(400).entity(Map.of("error", "No file provided")).build();
|
||||
}
|
||||
|
||||
String filename = upload.fileName();
|
||||
if (title == null || title.isBlank()) {
|
||||
title = filename.endsWith(".zip") ? filename.substring(0, filename.length() - 4) : filename;
|
||||
}
|
||||
|
||||
String gameId = GameService.sanitizeId(title);
|
||||
|
||||
// Ensure unique ID
|
||||
String baseId = gameId;
|
||||
int counter = 2;
|
||||
while (Files.exists(svc.gameJsonPath(gameId))) {
|
||||
gameId = baseId + "-" + counter++;
|
||||
}
|
||||
|
||||
// Extract ZIP
|
||||
Path tmpDir = Files.createTempDirectory("dostalgia-");
|
||||
try {
|
||||
Path zipPath = tmpDir.resolve(filename);
|
||||
Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
Path extractDir = tmpDir.resolve("extracted");
|
||||
unzip(zipPath, extractDir);
|
||||
|
||||
// Find main executable
|
||||
String mainExe = findMainExe(extractDir);
|
||||
if (mainExe == null) {
|
||||
return Response.status(400)
|
||||
.entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive"))
|
||||
.build();
|
||||
}
|
||||
|
||||
// Determine bundle strategy
|
||||
double sizeMb = dirSizeMB(extractDir);
|
||||
String bundleType = sizeMb > SOCKDRIVE_THRESHOLD_MB ? "sockdrive" : "standard";
|
||||
|
||||
// Create game directory
|
||||
Path gameDir = svc.gameDir(gameId);
|
||||
Files.createDirectories(gameDir);
|
||||
|
||||
String bundleFile;
|
||||
if ("sockdrive".equals(bundleType)) {
|
||||
// Copy files loose
|
||||
copyDir(extractDir, gameDir);
|
||||
// Write dosbox.conf
|
||||
Path jsdos = gameDir.resolve(".jsdos");
|
||||
Files.createDirectories(jsdos);
|
||||
String relExe = Path.of(mainExe).getFileName().toString();
|
||||
String conf = "[autoexec]\n@echo off\nmount c .\nc:\ncls\n" + relExe + "\n";
|
||||
Files.writeString(jsdos.resolve("dosbox.conf"), conf);
|
||||
bundleFile = ".jsdos/dosbox.conf";
|
||||
} else {
|
||||
// Create .jsdos bundle (flat in games dir)
|
||||
bundleFile = gameId + ".jsdos";
|
||||
Path bundlePath = svc.getGamesDir().resolve(bundleFile);
|
||||
createBundle(extractDir, mainExe, bundlePath);
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
Game game = new Game(gameId, title);
|
||||
game.setBundleType(bundleType);
|
||||
game.setBundleFile(bundleFile);
|
||||
game.setReady(true);
|
||||
svc.save(game);
|
||||
|
||||
return Response.status(201).entity(game).build();
|
||||
|
||||
} finally {
|
||||
deleteDir(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/cover")
|
||||
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||
public Response uploadCover(@PathParam("id") String id, @RestForm("file") FileUpload upload) throws Exception {
|
||||
if (upload == null) {
|
||||
return Response.status(400).entity(Map.of("error", "No file provided")).build();
|
||||
}
|
||||
if (!Files.exists(svc.gameJsonPath(id))) {
|
||||
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||
}
|
||||
|
||||
String fname = upload.fileName().toLowerCase();
|
||||
String ext = "";
|
||||
if (fname.endsWith(".jpg") || fname.endsWith(".jpeg")) ext = ".jpg";
|
||||
else if (fname.endsWith(".png")) ext = ".png";
|
||||
else if (fname.endsWith(".webp")) ext = ".webp";
|
||||
else {
|
||||
return Response.status(400).entity(Map.of("error", "Only JPG, PNG, or WebP images accepted")).build();
|
||||
}
|
||||
|
||||
Path gameDir = svc.gameDir(id);
|
||||
// Remove old covers
|
||||
for (String old : new String[]{".jpg", ".jpeg", ".png", ".webp"}) {
|
||||
Files.deleteIfExists(gameDir.resolve("cover" + old));
|
||||
}
|
||||
|
||||
Files.copy(upload.uploadedFile(), gameDir.resolve("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
|
||||
return Response.ok(Map.of("status", "ok")).build();
|
||||
}
|
||||
|
||||
// ─── ZIP Handling ────────────────────────────────────────────
|
||||
|
||||
private void unzip(Path zipPath, Path dest) throws IOException {
|
||||
try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
Path target = dest.resolve(entry.getName()).normalize();
|
||||
if (!target.startsWith(dest)) continue; // prevent traversal
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(target);
|
||||
} else {
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Executable Detection ────────────────────────────────────
|
||||
|
||||
private String findMainExe(Path dir) throws IOException {
|
||||
record Candidate(int depth, boolean isInstaller, long size, String path) {}
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
String name = f.getFileName().toString().toLowerCase();
|
||||
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
|
||||
return FileVisitResult.CONTINUE;
|
||||
|
||||
boolean installer = name.contains("install") || name.contains("setup") || name.contains("config");
|
||||
int depth = f.getNameCount() - dir.getNameCount();
|
||||
candidates.add(new Candidate(depth, installer, a.size(), f.toString()));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
|
||||
candidates.sort(Comparator
|
||||
.comparingInt(Candidate::depth)
|
||||
.thenComparingInt(c -> c.isInstaller() ? 1 : 0)
|
||||
.thenComparingLong(c -> -c.size())); // larger files first
|
||||
|
||||
return candidates.getFirst().path();
|
||||
}
|
||||
|
||||
// ─── Bundle Creation ─────────────────────────────────────────
|
||||
|
||||
private void createBundle(Path extractDir, String mainExe, Path bundlePath) throws IOException {
|
||||
// Create temporary bundle dir
|
||||
Path tmpBundle = Files.createTempDirectory("dostalgia-bundle-");
|
||||
try {
|
||||
copyDir(extractDir, tmpBundle);
|
||||
|
||||
// Create .jsdos config
|
||||
Path jsdos = tmpBundle.resolve(".jsdos");
|
||||
Files.createDirectories(jsdos);
|
||||
String relExe = Path.of(mainExe).getFileName().toString();
|
||||
String conf = "[autoexec]\n@echo off\nmount c .\nc:\ncls\n" + relExe + "\n";
|
||||
Files.writeString(jsdos.resolve("dosbox.conf"), conf);
|
||||
|
||||
// ZIP it up
|
||||
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
|
||||
Files.walk(tmpBundle).filter(Files::isRegularFile).forEach(f -> {
|
||||
try {
|
||||
String entryName = tmpBundle.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
deleteDir(tmpBundle);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File Utilities ──────────────────────────────────────────
|
||||
|
||||
private double dirSizeMB(Path dir) throws IOException {
|
||||
long[] total = {0};
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
|
||||
total[0] += a.size();
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
return total[0] / (1024.0 * 1024.0);
|
||||
}
|
||||
|
||||
private void copyDir(Path src, Path dst) throws IOException {
|
||||
Files.walkFileTree(src, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes a) throws IOException {
|
||||
Files.createDirectories(dst.resolve(src.relativize(d)));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
Files.copy(f, dst.resolve(src.relativize(f)), StandardCopyOption.REPLACE_EXISTING);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void deleteDir(Path dir) throws IOException {
|
||||
if (!Files.exists(dir)) return;
|
||||
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
|
||||
Files.delete(f);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
|
||||
Files.delete(d);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# ─── Server ──────────────────────────────────────
|
||||
quarkus.http.port=8765
|
||||
quarkus.http.host=0.0.0.0
|
||||
quarkus.http.cors=true
|
||||
|
||||
# ─── Static resources (frontend SPA from META-INF/resources/) ──
|
||||
quarkus.http.static-resources.enable=true
|
||||
|
||||
# ─── Data directory (game files, artwork) ────────
|
||||
dostalgia.data.dir=/data
|
||||
Reference in New Issue
Block a user