diff --git a/.gitignore b/.gitignore index 3191820..397063d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,17 @@ # Dependencies node_modules/ -frontend/dist/ frontend/node_modules/ +# Built output +frontend/dist/ +dist/ + +# Maven / Java +target/ +*.class +*.jar +*.war + # Runtime data data/games/* data/artwork/* @@ -11,8 +20,12 @@ data/saves/* !data/artwork/.gitkeep !data/saves/.gitkeep -# Binary -dostalgia +# IDE +.vscode/ +.idea/ +*.iml +*.ipr +*.iws # Test/upload artifacts *.zip @@ -20,6 +33,6 @@ dostalgia *.jsdos *.JSDOS -# IDE -.vscode/ -.idea/ +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile index b651953..97b7d5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Dostalgia — Multi-stage Docker build +# Dostalgia — Quarkus multi-stage build # ─── 1. Build frontend ─────────────────────────── FROM node:20-bookworm AS frontend @@ -6,22 +6,23 @@ WORKDIR /build COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci 2>/dev/null || npm install --legacy-peer-deps COPY frontend/ ./ -RUN npx vite build +RUN npm run build -# ─── 2. Build Go binary ────────────────────────── -FROM golang:1.23-bookworm AS gobuild +# ─── 2. Build Quarkus app ──────────────────────── +FROM maven:3-eclipse-temurin-21 AS build WORKDIR /build -COPY go.mod ./ -COPY *.go ./ +COPY pom.xml ./ +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/ -RUN CGO_ENABLED=0 go build -o /dostalgia . +RUN mvn package -DskipTests -q # ─── 3. Runtime ────────────────────────────────── -FROM debian:bookworm-slim -RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/* -COPY --from=gobuild /dostalgia /dostalgia -VOLUME /data +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +# Quarkus uber-jar layout +COPY --from=build /build/target/quarkus-app/ /app/ EXPOSE 8765 +VOLUME /data ENV DOSTALGIA_DATA=/data -ENV PORT=8765 -ENTRYPOINT ["/dostalgia"] +ENTRYPOINT ["java", "-jar", "quarkus-run.jar"] diff --git a/README.md b/README.md index 5de04a3..8e0a4b2 100644 --- a/README.md +++ b/README.md @@ -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 +# Build and run with Docker docker build -t dostalgia . docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia ``` -## Development +Open http://localhost:8765 -Terminal 1 — Go API: -```bash -DOSTALGIA_DEV=1 go run . -``` +## Development (without Docker) -Terminal 2 — Frontend dev server: +Terminal 1 — Frontend dev server: ```bash 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 -- **Backend**: Single Go binary. No database — each game is a directory with a `game.json` file. -- **Frontend**: Svelte SPA, embedded in the Go binary at build time. -- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly. -- **Storage**: `/data/games/{id}/` — contains `game.json`, `.jsdos` bundle, and cover art. +- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero database +- **Frontend**: Svelte 5 SPA with hash-based routing +- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly +- **Storage**: JSON-per-game under `/data/games/{id}/game.json` ## API @@ -51,8 +61,6 @@ Open http://localhost:5173 "year": 1993, "genre": "FPS", "developer": "id Software", - "publisher": "id Software", - "description": "The iconic first-person shooter...", "bundle_type": "standard", "bundle_file": "doom.jsdos", "has_cover": true, diff --git a/docker-compose.yml b/docker-compose.yml index a83710e..dbdcdba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ services: - ./data:/data environment: - DOSTALGIA_DATA=/data - - PORT=8765 # IGDB credentials (optional): # - TWITCH_CLIENT_ID=your_client_id # - TWITCH_CLIENT_SECRET=your_client_secret diff --git a/game.go b/game.go deleted file mode 100644 index 9ad0fad..0000000 --- a/game.go +++ /dev/null @@ -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) -} diff --git a/go.mod b/go.mod deleted file mode 100644 index aa177a2..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module dostalgia - -go 1.23.9 diff --git a/igdb.go b/igdb.go deleted file mode 100644 index 6204a49..0000000 --- a/igdb.go +++ /dev/null @@ -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, - }, - }) -} diff --git a/main.go b/main.go deleted file mode 100644 index 288eb13..0000000 --- a/main.go +++ /dev/null @@ -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) -} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..596518a --- /dev/null +++ b/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + com.dostalgia + dostalgia + 0.1.0 + + + 21 + 21 + 21 + UTF-8 + 3.19.2 + io.quarkus.platform + + + + + + ${quarkus.platform.group-id} + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-rest + + + + io.quarkus + quarkus-rest-jackson + + + + io.quarkus + quarkus-rest-multipart + + + + io.quarkus + quarkus-vertx-http + + + + io.quarkus + quarkus-arc + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-frontend + generate-resources + copy-resources + + ${project.build.directory}/classes/META-INF/resources + + + frontend/dist + false + + + + + + + + + diff --git a/src/main/java/com/dostalgia/Game.java b/src/main/java/com/dostalgia/Game.java new file mode 100644 index 0000000..f69a0c2 --- /dev/null +++ b/src/main/java/com/dostalgia/Game.java @@ -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 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 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 getPlatforms() { return platforms; } + public void setPlatforms(List 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 getScreenshots() { return screenshots; } + public void setScreenshots(List 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; } +} diff --git a/src/main/java/com/dostalgia/GameResource.java b/src/main/java/com/dostalgia/GameResource.java new file mode 100644 index 0000000..94e7e12 --- /dev/null +++ b/src/main/java/com/dostalgia/GameResource.java @@ -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 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; + } +} diff --git a/src/main/java/com/dostalgia/GameService.java b/src/main/java/com/dostalgia/GameService.java new file mode 100644 index 0000000..9a22f65 --- /dev/null +++ b/src/main/java/com/dostalgia/GameService.java @@ -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 list() throws IOException { + List games = new ArrayList<>(); + if (!Files.isDirectory(gamesDir)) return games; + + try (var stream = Files.list(gamesDir)) { + for (Path dir : (Iterable) 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; } +} diff --git a/src/main/java/com/dostalgia/IgdbResource.java b/src/main/java/com/dostalgia/IgdbResource.java new file mode 100644 index 0000000..a853333 --- /dev/null +++ b/src/main/java/com/dostalgia/IgdbResource.java @@ -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(); + } +} diff --git a/src/main/java/com/dostalgia/SockdriveResource.java b/src/main/java/com/dostalgia/SockdriveResource.java new file mode 100644 index 0000000..430f943 --- /dev/null +++ b/src/main/java/com/dostalgia/SockdriveResource.java @@ -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>(); + 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(); + } +} diff --git a/src/main/java/com/dostalgia/StaticResource.java b/src/main/java/com/dostalgia/StaticResource.java new file mode 100644 index 0000000..faf9736 --- /dev/null +++ b/src/main/java/com/dostalgia/StaticResource.java @@ -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); + } + } +} diff --git a/src/main/java/com/dostalgia/UploadResource.java b/src/main/java/com/dostalgia/UploadResource.java new file mode 100644 index 0000000..3674ec1 --- /dev/null +++ b/src/main/java/com/dostalgia/UploadResource.java @@ -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 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; + } + }); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..aa95219 --- /dev/null +++ b/src/main/resources/application.properties @@ -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 diff --git a/upload.go b/upload.go deleted file mode 100644 index cebfe46..0000000 --- a/upload.go +++ /dev/null @@ -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