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:
David Alvarez
2026-05-24 15:41:51 +02:00
parent 7f17f4b31e
commit 28ea711b24
18 changed files with 986 additions and 933 deletions
+19 -6
View File
@@ -1,8 +1,17 @@
# Dependencies # Dependencies
node_modules/ node_modules/
frontend/dist/
frontend/node_modules/ frontend/node_modules/
# Built output
frontend/dist/
dist/
# Maven / Java
target/
*.class
*.jar
*.war
# Runtime data # Runtime data
data/games/* data/games/*
data/artwork/* data/artwork/*
@@ -11,8 +20,12 @@ data/saves/*
!data/artwork/.gitkeep !data/artwork/.gitkeep
!data/saves/.gitkeep !data/saves/.gitkeep
# Binary # IDE
dostalgia .vscode/
.idea/
*.iml
*.ipr
*.iws
# Test/upload artifacts # Test/upload artifacts
*.zip *.zip
@@ -20,6 +33,6 @@ dostalgia
*.jsdos *.jsdos
*.JSDOS *.JSDOS
# IDE # OS
.vscode/ .DS_Store
.idea/ Thumbs.db
+14 -13
View File
@@ -1,4 +1,4 @@
# Dostalgia — Multi-stage Docker build # Dostalgia — Quarkus multi-stage build
# ─── 1. Build frontend ─────────────────────────── # ─── 1. Build frontend ───────────────────────────
FROM node:20-bookworm AS frontend FROM node:20-bookworm AS frontend
@@ -6,22 +6,23 @@ WORKDIR /build
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
COPY frontend/ ./ COPY frontend/ ./
RUN npx vite build RUN npm run build
# ─── 2. Build Go binary ────────────────────────── # ─── 2. Build Quarkus app ────────────────────────
FROM golang:1.23-bookworm AS gobuild FROM maven:3-eclipse-temurin-21 AS build
WORKDIR /build WORKDIR /build
COPY go.mod ./ COPY pom.xml ./
COPY *.go ./ COPY src ./src
# Copy the built frontend into the source tree so maven-resources-plugin picks it up
COPY --from=frontend /build/dist ./frontend/dist/ COPY --from=frontend /build/dist ./frontend/dist/
RUN CGO_ENABLED=0 go build -o /dostalgia . RUN mvn package -DskipTests -q
# ─── 3. Runtime ────────────────────────────────── # ─── 3. Runtime ──────────────────────────────────
FROM debian:bookworm-slim FROM eclipse-temurin:21-jre-alpine
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/* WORKDIR /app
COPY --from=gobuild /dostalgia /dostalgia # Quarkus uber-jar layout
VOLUME /data COPY --from=build /build/target/quarkus-app/ /app/
EXPOSE 8765 EXPOSE 8765
VOLUME /data
ENV DOSTALGIA_DATA=/data ENV DOSTALGIA_DATA=/data
ENV PORT=8765 ENTRYPOINT ["java", "-jar", "quarkus-run.jar"]
ENTRYPOINT ["/dostalgia"]
+24 -16
View File
@@ -1,34 +1,44 @@
# Dostalgia # Dostalgia — Quarkus Edition
A nostalgic DOS game hub — upload, scrape artwork, and play classic DOS games directly in the browser via js-dos. A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
## Quick Start (Docker) ## Quick Start
```bash ```bash
# Build and run with Docker
docker build -t dostalgia . docker build -t dostalgia .
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
``` ```
## Development Open http://localhost:8765
Terminal 1 — Go API: ## Development (without Docker)
```bash
DOSTALGIA_DEV=1 go run .
```
Terminal 2 — Frontend dev server: Terminal 1 — Frontend dev server:
```bash ```bash
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
``` ```
Open http://localhost:5173 Terminal 2 — Quarkus dev server:
```bash
mvn quarkus:dev
```
Open http://localhost:5173 (Vite proxies API calls to Quarkus on port 8765)
### Production build (local)
```bash
cd frontend && npm install && npm run build
mvn package -DskipTests
java -jar target/quarkus-app/quarkus-run.jar
```
## Architecture ## Architecture
- **Backend**: Single Go binary. No database — each game is a directory with a `game.json` file. - **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero database
- **Frontend**: Svelte SPA, embedded in the Go binary at build time. - **Frontend**: Svelte 5 SPA with hash-based routing
- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly. - **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly
- **Storage**: `/data/games/{id}/` — contains `game.json`, `.jsdos` bundle, and cover art. - **Storage**: JSON-per-game under `/data/games/{id}/game.json`
## API ## API
@@ -51,8 +61,6 @@ Open http://localhost:5173
"year": 1993, "year": 1993,
"genre": "FPS", "genre": "FPS",
"developer": "id Software", "developer": "id Software",
"publisher": "id Software",
"description": "The iconic first-person shooter...",
"bundle_type": "standard", "bundle_type": "standard",
"bundle_file": "doom.jsdos", "bundle_file": "doom.jsdos",
"has_cover": true, "has_cover": true,
-1
View File
@@ -8,7 +8,6 @@ services:
- ./data:/data - ./data:/data
environment: environment:
- DOSTALGIA_DATA=/data - DOSTALGIA_DATA=/data
- PORT=8765
# IGDB credentials (optional): # IGDB credentials (optional):
# - TWITCH_CLIENT_ID=your_client_id # - TWITCH_CLIENT_ID=your_client_id
# - TWITCH_CLIENT_SECRET=your_client_secret # - TWITCH_CLIENT_SECRET=your_client_secret
-156
View File
@@ -1,156 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
// Game represents the metadata for a single DOS game.
// Stored as game.json in data/games/{id}/
type Game struct {
ID string `json:"id"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Genre string `json:"genre,omitempty"`
Developer string `json:"developer,omitempty"`
Publisher string `json:"publisher,omitempty"`
Description string `json:"description,omitempty"`
Rating float64 `json:"rating,omitempty"`
Platforms []string `json:"platforms,omitempty"`
// Bundle info
BundleType string `json:"bundle_type,omitempty"` // "standard" or "sockdrive"
BundleFile string `json:"bundle_file,omitempty"` // path relative to game dir
// IGDB
IGDBID int `json:"igdb_id,omitempty"`
IGDBCoverID string `json:"igdb_cover_id,omitempty"`
// Local file hints (cover exists if cover.jpg/png/webp is present)
HasCover bool `json:"has_cover"`
HasScreenshot bool `json:"has_screenshots"`
Screenshots []string `json:"screenshots,omitempty"`
Ready bool `json:"ready"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// gameDir returns the directory for a game by ID.
func gameDir(id string) string {
return filepath.Join(dataDir, "games", id)
}
// gameJSONPath returns the path to a game's game.json.
func gameJSONPath(id string) string {
return filepath.Join(gameDir(id), "game.json")
}
// loadGame reads a game.json file and returns the Game.
func loadGame(id string) (*Game, error) {
path := gameJSONPath(id)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
g := &Game{}
if err := json.Unmarshal(data, g); err != nil {
return nil, err
}
// Detect if cover file exists
g.HasCover = fileExists(filepath.Join(gameDir(id), "cover.jpg")) ||
fileExists(filepath.Join(gameDir(id), "cover.png")) ||
fileExists(filepath.Join(gameDir(id), "cover.webp"))
return g, nil
}
// saveGame writes a game's metadata to its game.json.
func saveGame(g *Game) error {
g.UpdatedAt = time.Now()
if g.CreatedAt.IsZero() {
g.CreatedAt = time.Now()
}
dir := gameDir(g.ID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
return os.WriteFile(gameJSONPath(g.ID), data, 0644)
}
// listGames scans the games directory and returns all games, sorted by title.
func listGames() ([]*Game, error) {
gamesDir := filepath.Join(dataDir, "games")
entries, err := os.ReadDir(gamesDir)
if err != nil {
if os.IsNotExist(err) {
return []*Game{}, nil
}
return nil, err
}
var games []*Game
if len(entries) > 0 {
games = make([]*Game, 0, len(entries))
} else {
games = make([]*Game, 0)
}
for _, e := range entries {
if !e.IsDir() {
continue
}
g, err := loadGame(e.Name())
if err != nil {
continue // skip broken entries
}
games = append(games, g)
}
sort.Slice(games, func(i, j int) bool {
return games[i].Title < games[j].Title
})
return games, nil
}
// fileExists checks if a file exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// sanitizeID cleans a string for use as a game directory name.
func sanitizeID(s string) string {
result := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
result = append(result, c)
} else if c >= 'A' && c <= 'Z' {
result = append(result, c+32) // lowercase
} else if c == ' ' || c == '-' || c == '_' {
result = append(result, '-')
}
}
// Trim leading/trailing hyphens
for len(result) > 0 && result[0] == '-' {
result = result[1:]
}
for len(result) > 0 && result[len(result)-1] == '-' {
result = result[:len(result)-1]
}
if len(result) == 0 {
return "unknown"
}
return string(result)
}
-3
View File
@@ -1,3 +0,0 @@
module dostalgia
go 1.23.9
-29
View File
@@ -1,29 +0,0 @@
package main
import "net/http"
// handleIGDBSearch handles GET /api/igdb/search — placeholder.
// Once IGDB credentials are configured, this will query the Twitch API.
func handleIGDBSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Query parameter 'q' required", http.StatusBadRequest)
return
}
// TODO: Implement real IGDB search.
// Requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET environment variables.
//
// Flow:
// 1. POST https://id.twitch.tv/oauth2/token for access token
// 2. POST https://api.igdb.com/v4/games with body: search "{query}"; fields name,cover.*,genres.*,screenshots.*;
// 3. Resolve cover URLs → https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg
// 4. Return matches
writeJSON(w, http.StatusOK, []map[string]string{
{
"message": "IGDB integration not yet configured. Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars.",
"query": query,
},
})
}
-310
View File
@@ -1,310 +0,0 @@
package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
//go:embed all:frontend/dist
var frontendFS embed.FS
var (
dataDir string
devMode bool
)
func main() {
// Determine data directory
dataDir = os.Getenv("DOSTALGIA_DATA")
if dataDir == "" {
dataDir = "data"
}
devMode = os.Getenv("DOSTALGIA_DEV") == "1"
// Ensure data directories exist
for _, d := range []string{"games", "artwork", "saves"} {
os.MkdirAll(filepath.Join(dataDir, d), 0755)
}
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/health", handleHealth)
mux.HandleFunc("/api/games", handleGames)
mux.HandleFunc("/api/games/", handleGameByID)
mux.HandleFunc("/api/upload", handleUploadGame)
mux.HandleFunc("/api/igdb/search", handleIGDBSearch)
// Serve game bundles and artwork as static files
gameFS := http.FileServer(http.Dir(filepath.Join(dataDir, "games")))
mux.Handle("/games/", http.StripPrefix("/games/", gameFS))
artFS := http.FileServer(http.Dir(filepath.Join(dataDir, "artwork")))
mux.Handle("/artwork/", http.StripPrefix("/artwork/", artFS))
// Sockdrive file server
mux.HandleFunc("/api/sockdrive/", handleSockdrive)
// Frontend SPA — serve embedded files, fall back to index.html
mux.HandleFunc("/", handleFrontend)
port := os.Getenv("PORT")
if port == "" {
port = "8765"
}
log.Printf("🎮 Dostalgia starting on :%s (data: %s)", port, dataDir)
if devMode {
log.Println("⚠️ DEV mode — run Vite dev server alongside for hot reload")
}
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
// ─── API Handlers ────────────────────────────────────────────────
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"version": "0.1.0",
})
}
func handleGames(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
games, err := listGames()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"games": games,
"total": len(games),
})
case http.MethodPost:
// Create a game manually (without upload)
var g Game
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if g.Title == "" {
http.Error(w, "Title is required", http.StatusBadRequest)
return
}
if g.ID == "" {
g.ID = sanitizeID(g.Title)
}
if err := saveGame(&g); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, g)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleGameByID(w http.ResponseWriter, r *http.Request) {
// Extract ID from /api/games/{id} or /api/games/{id}/...
path := strings.TrimPrefix(r.URL.Path, "/api/games/")
parts := strings.SplitN(path, "/", 2)
id := parts[0]
if id == "" {
http.Error(w, "Game ID required", http.StatusBadRequest)
return
}
// Cover upload sub-endpoint
if len(parts) > 1 && parts[1] == "cover" {
handleUploadCover(w, r)
return
}
switch r.Method {
case http.MethodGet:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, g)
case http.MethodPatch:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Apply simple string/number fields
if v, ok := updates["title"].(string); ok {
g.Title = v
}
if v, ok := updates["year"].(float64); ok {
g.Year = int(v)
}
if v, ok := updates["genre"].(string); ok {
g.Genre = v
}
if v, ok := updates["developer"].(string); ok {
g.Developer = v
}
if v, ok := updates["publisher"].(string); ok {
g.Publisher = v
}
if v, ok := updates["description"].(string); ok {
g.Description = v
}
if v, ok := updates["rating"].(float64); ok {
g.Rating = v
}
if err := saveGame(g); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, g)
case http.MethodDelete:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
// Remove the game directory (game.json, cover, sockdrive files)
os.RemoveAll(gameDir(g.ID))
// Also remove the flat .jsdos bundle if it exists
bundlePath := filepath.Join(dataDir, "games", g.ID+".jsdos")
os.Remove(bundlePath)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ─── Sockdrive File Server ──────────────────────────────────────
func handleSockdrive(w http.ResponseWriter, r *http.Request) {
// /api/sockdrive/{slug}/file?path=...
path := strings.TrimPrefix(r.URL.Path, "/api/sockdrive/")
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
slug := parts[0]
if parts[1] == "file" {
filePath := r.URL.Query().Get("path")
if filePath == "" {
http.Error(w, "path query parameter required", http.StatusBadRequest)
return
}
fullPath := filepath.Join(gameDir(slug), filePath)
// Prevent traversal
fullPath = filepath.Clean(fullPath)
if !strings.HasPrefix(fullPath, filepath.Clean(gameDir(slug))) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, fullPath)
return
}
if parts[1] == "files" {
// List all files in game directory
var files []map[string]interface{}
gameDirPath := gameDir(slug)
filepath.WalkDir(gameDirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, _ := d.Info()
rel, _ := filepath.Rel(gameDirPath, path)
files = append(files, map[string]interface{}{
"path": strings.ReplaceAll(rel, "\\", "/"),
"size": info.Size(),
})
return nil
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"files": files,
"total": len(files),
})
return
}
http.Error(w, "Not found", http.StatusNotFound)
}
// ─── Frontend SPA ───────────────────────────────────────────────
func handleFrontend(w http.ResponseWriter, r *http.Request) {
// Don't intercept API or static routes
if strings.HasPrefix(r.URL.Path, "/api/") ||
strings.HasPrefix(r.URL.Path, "/games/") ||
strings.HasPrefix(r.URL.Path, "/artwork/") {
http.NotFound(w, r)
return
}
var content fs.FS
var err error
if devMode {
content = os.DirFS("frontend/dist")
} else {
content, err = fs.Sub(frontendFS, "frontend/dist")
if err != nil {
http.Error(w, "Frontend not built", http.StatusServiceUnavailable)
return
}
}
// Try to serve the exact file
filePath := strings.TrimPrefix(r.URL.Path, "/")
if filePath == "" {
filePath = "index.html"
}
f, err := content.Open(filePath)
if err == nil {
f.Close()
http.FileServer(http.FS(content)).ServeHTTP(w, r)
return
}
// Fallback to index.html for SPA routing
indexData, err := fs.ReadFile(content, "index.html")
if err != nil {
http.Error(w, "Frontend not built. Run 'cd frontend && npm run build'", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexData)
}
// ─── Helpers ────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
+99
View File
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.dostalgia</groupId>
<artifactId>dostalgia</artifactId>
<version>0.1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.19.2</quarkus.platform.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- REST API (JAX-RS with Reactive) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<!-- JSON serialization -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- Multipart file upload -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-multipart</artifactId>
</dependency>
<!-- Serve static files from classpath -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-vertx-http</artifactId>
</dependency>
<!-- Arc CDI (included, needed for @Singleton etc) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Copy built frontend into classpath resources -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend</id>
<phase>generate-resources</phase>
<goals><goal>copy-resources</goal></goals>
<configuration>
<outputDirectory>${project.build.directory}/classes/META-INF/resources</outputDirectory>
<resources>
<resource>
<directory>frontend/dist</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+106
View File
@@ -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;
}
});
}
}
+10
View File
@@ -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
-399
View File
@@ -1,399 +0,0 @@
package main
import (
"archive/zip"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const dosboxConfTemplate = `[autoexec]
@echo off
mount c .
c:
cls
%s
`
// handleUploadGame handles POST /api/upload — receives a ZIP, creates a .jsdos bundle.
func handleUploadGame(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse multipart form (max 500MB)
if err := r.ParseMultipartForm(500 << 20); err != nil {
http.Error(w, "Failed to parse upload: "+err.Error(), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
title := r.FormValue("title")
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
gameID := sanitizeID(title)
// Ensure uniqueness
baseID := gameID
counter := 2
for {
if _, err := loadGame(gameID); err != nil {
break // not found, we can use this ID
}
gameID = fmt.Sprintf("%s-%d", baseID, counter)
counter++
}
// Save uploaded ZIP to temp file
tmpDir, err := os.MkdirTemp("", "dostalgia-upload-")
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
defer os.RemoveAll(tmpDir)
zipPath := filepath.Join(tmpDir, header.Filename)
dst, err := os.Create(zipPath)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
http.Error(w, "Failed to save upload", http.StatusInternalServerError)
return
}
dst.Close()
// Extract ZIP
extractDir := filepath.Join(tmpDir, "extracted")
if err := os.MkdirAll(extractDir, 0755); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if err := extractZip(zipPath, extractDir); err != nil {
http.Error(w, "Invalid ZIP file: "+err.Error(), http.StatusBadRequest)
return
}
// Find main executable
mainExe := findMainExe(extractDir)
if mainExe == "" {
http.Error(w, "No executable (.exe/.com/.bat) found in the archive", http.StatusBadRequest)
return
}
// Determine size and strategy
sizeMB := dirSizeMB(extractDir)
bundleType := "standard"
if sizeMB > 80 {
bundleType = "sockdrive"
}
// Set up game directory
gameDirPath := gameDir(gameID)
if err := os.MkdirAll(gameDirPath, 0755); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
var bundleFile string
if bundleType == "sockdrive" {
// Copy all files loose
if err := copyDir(extractDir, gameDirPath); err != nil {
http.Error(w, "Failed to store game files", http.StatusInternalServerError)
return
}
// Create dosbox.conf
confDir := filepath.Join(gameDirPath, ".jsdos")
os.MkdirAll(confDir, 0755)
relExe, _ := filepath.Rel(extractDir, mainExe)
os.WriteFile(filepath.Join(confDir, "dosbox.conf"),
[]byte(fmt.Sprintf(dosboxConfTemplate, relExe)), 0644)
bundleFile = ".jsdos/dosbox.conf"
} else {
// Create .jsdos ZIP bundle flat in data/games/
bundleFile = gameID + ".jsdos"
bundlePath := filepath.Join(filepath.Dir(gameDirPath), bundleFile)
if err := createJSDOSBundle(extractDir, mainExe, bundlePath); err != nil {
http.Error(w, "Failed to create bundle: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Save metadata
game := &Game{
ID: gameID,
Title: title,
BundleType: bundleType,
BundleFile: bundleFile,
Ready: true,
}
if err := saveGame(game); err != nil {
http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, game)
}
// extractZip extracts a ZIP file to the given directory.
func extractZip(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
// Prevent path traversal
target := filepath.Join(dest, f.Name)
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(target, 0755)
continue
}
os.MkdirAll(filepath.Dir(target), 0755)
src, err := f.Open()
if err != nil {
return err
}
dst, err := os.Create(target)
if err != nil {
src.Close()
return err
}
_, err = io.Copy(dst, src)
src.Close()
dst.Close()
if err != nil {
return err
}
}
return nil
}
// mainExeCandidate is used for sorting executables by relevance.
type mainExeCandidate struct {
depth int
isInstaller bool
size int64
name string
path string
}
// findMainExe finds the most likely main executable in an extracted game directory.
func findMainExe(dir string) string {
var candidates []mainExeCandidate
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(d.Name()))
if ext != ".exe" && ext != ".com" && ext != ".bat" {
return nil
}
info, _ := d.Info()
name := strings.ToLower(d.Name())
isInstaller := strings.Contains(name, "install") ||
strings.Contains(name, "setup") ||
strings.Contains(name, "config")
depth := strings.Count(path[len(dir):], string(os.PathSeparator))
candidates = append(candidates, mainExeCandidate{
depth: depth,
isInstaller: isInstaller,
size: info.Size(),
name: name,
path: path,
})
return nil
})
if len(candidates) == 0 {
return ""
}
// Sort: shallow depth, non-installer, larger files first
sortCandidates(candidates)
return candidates[0].path
}
func sortCandidates(c []mainExeCandidate) {
for i := 0; i < len(c); i++ {
for j := i + 1; j < len(c); j++ {
// Compare: depth (lower first), installer (false first), size (larger first)
swap := false
if c[i].depth != c[j].depth {
swap = c[i].depth > c[j].depth
} else if c[i].isInstaller != c[j].isInstaller {
swap = c[i].isInstaller
} else {
swap = c[i].size < c[j].size
}
if swap {
c[i], c[j] = c[j], c[i]
}
}
}
}
// dirSizeMB returns the total size of a directory in megabytes.
func dirSizeMB(dir string) float64 {
var total int64
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, _ := d.Info()
total += info.Size()
return nil
})
return float64(total) / (1024 * 1024)
}
// copyDir copies a directory recursively.
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, path)
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0644)
})
}
// createJSDOSBundle creates a .jsdos bundle ZIP from extracted game files.
func createJSDOSBundle(extractDir, mainExe, bundlePath string) error {
// Create a temp directory for bundling
tmpDir, err := os.MkdirTemp("", "dostalgia-bundle-")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
// Copy game files
if err := copyDir(extractDir, tmpDir); err != nil {
return err
}
// Create .jsdos config
jsdosDir := filepath.Join(tmpDir, ".jsdos")
if err := os.MkdirAll(jsdosDir, 0755); err != nil {
return err
}
relExe, _ := filepath.Rel(extractDir, mainExe)
conf := fmt.Sprintf(dosboxConfTemplate, relExe)
if err := os.WriteFile(filepath.Join(jsdosDir, "dosbox.conf"), []byte(conf), 0644); err != nil {
return err
}
// Create the ZIP
zf, err := os.Create(bundlePath)
if err != nil {
return err
}
defer zf.Close()
zw := zip.NewWriter(zf)
defer zw.Close()
return filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(tmpDir, path)
w, err := zw.Create(rel)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = w.Write(data)
return err
})
}
// handleUploadCover handles POST /api/games/{id}/cover — upload cover art.
func handleUploadCover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
id := r.PathValue("id")
if _, err := loadGame(id); err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "No file provided", http.StatusBadRequest)
return
}
defer file.Close()
// Determine extension
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
http.Error(w, "Only JPG, PNG, or WebP images accepted", http.StatusBadRequest)
return
}
coverPath := filepath.Join(gameDir(id), "cover"+ext)
// Remove old cover files
for _, oldExt := range []string{".jpg", ".jpeg", ".png", ".webp"} {
os.Remove(filepath.Join(gameDir(id), "cover"+oldExt))
}
dst, err := os.Create(coverPath)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "Failed to save cover", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "path": coverPath})
}
var _ = time.Now // keep import