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:
+19
-6
@@ -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
@@ -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"]
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user