Compare commits

..

7 Commits

Author SHA1 Message Date
droideparanoico 8324ecd1e7 Simplified gitea workflow
Build & Deploy / build-and-deploy (push) Successful in 36s
2026-07-07 16:43:02 +02:00
droideparanoico 0567a1d29b Added dostalgia icons
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-18 12:56:32 +02:00
droideparanoico f2360cdae8 Gitea workflow
Build & Deploy / build-and-deploy (push) Successful in 11s
2026-06-14 14:26:42 +02:00
droideparanoico 59245a469d Updated screenshot 2026-06-14 14:09:09 +02:00
droideparanoico ab35e52728 Version bump 2026-06-14 13:56:40 +02:00
droideparanoico d2ce7f0fdc Only show Upload button on library view 2026-06-14 13:53:20 +02:00
droideparanoico a61f3a9c33 Initial commit 2026-06-11 17:29:10 +02:00
57 changed files with 4338 additions and 577 deletions
+10
View File
@@ -0,0 +1,10 @@
# DOStalgia — Environment Configuration
# Copy this file to .env and fill in your values:
# cp .env.example .env
# Twitch Client ID for IGDB metadata auto-scrape.
# Get yours at https://dev.twitch.tv/console/apps → Create Application
# (Set OAuth Redirect URL to http://localhost, Category to "Other")
# Optional — IGDB features gracefully skip if not set.
# TWITCH_CLIENT_ID=your_client_id_here
# TWITCH_CLIENT_SECRET=your_client_secret_here
+1 -34
View File
@@ -1,7 +1,5 @@
# This workflow builds and deploys Dostalgia whenever code is pushed to main.
# This workflow builds DOStalgia whenever code is pushed to main.
# The runner has Docker socket access so it can manage containers on the host.
# Parameters match the docker-compose at:
# /appdata/dockhand/stacks/unraid/dostalgia/compose.yaml
# See: https://docs.gitea.com/usage/actions
name: Build & Deploy
@@ -13,8 +11,6 @@ on:
env:
IMAGE_NAME: dostalgia
CONTAINER_NAME: dostalgia
DATA_VOLUME: /mnt/cache/appdata/dostalgia
jobs:
build-and-deploy:
@@ -29,34 +25,5 @@ jobs:
docker build -t ${{ env.IMAGE_NAME }}:${{ gitea.sha }} .
docker tag ${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.IMAGE_NAME }}:latest
- name: Deploy container
run: |
docker stop ${{ env.CONTAINER_NAME }} 2>/dev/null || true
docker rm ${{ env.CONTAINER_NAME }} 2>/dev/null || true
docker run -d \
--name ${{ env.CONTAINER_NAME }} \
--restart unless-stopped \
--network dockernet \
-p 8765:8765/tcp \
-v ${{ env.DATA_VOLUME }}:/data \
-e TWITCH_CLIENT_ID='${{ secrets.TWITCH_CLIENT_ID }}' \
-e TWITCH_CLIENT_SECRET='${{ secrets.TWITCH_CLIENT_SECRET }}' \
${{ env.IMAGE_NAME }}:latest
- name: Verify container is running
run: |
sleep 5
STATUS=$(docker inspect -f '{{.State.Status}}' ${{ env.CONTAINER_NAME }})
if [ "$STATUS" = "running" ]; then
echo "✅ Dostalgia container is running"
echo "--- last logs ---"
docker logs ${{ env.CONTAINER_NAME }} --tail 5
echo "✅ Dostalgia deployed!"
else
echo "❌ Container status: $STATUS"
docker logs ${{ env.CONTAINER_NAME }} --tail 30
exit 1
fi
- name: Clean up old images
run: docker image prune -f --filter "until=24h"
+45
View File
@@ -0,0 +1,45 @@
name: Publish Docker image
on:
release:
types: [published]
jobs:
push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Extract tags
id: meta
uses: docker/metadata-action@v5
with:
images: droideparanoico/dostalgia
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7
View File
@@ -2,6 +2,10 @@
node_modules/
frontend/node_modules/
# Environment — secrets
.env
.env.local
# Built output
frontend/dist/
dist/
@@ -36,3 +40,6 @@ data/saves/*
# OS
.DS_Store
Thumbs.db
# Gitea
.gitea/
+3 -2
View File
@@ -1,4 +1,4 @@
# Dostalgia — Quarkus multi-stage build
# DOStalgia — Quarkus multi-stage build
# ─── 1. Build frontend ───────────────────────────
FROM node:20-alpine AS frontend
@@ -7,6 +7,7 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci 2>/dev/null || npm install --legacy-peer-deps
COPY frontend/ ./
RUN npm run build
RUN npm test
# ─── 2. Build Quarkus app ────────────────────────
FROM maven:3-eclipse-temurin-21-alpine AS build
@@ -15,7 +16,7 @@ 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 mvn package -DskipTests -q
RUN mvn package -q -DskipFrontend=true -DskipFrontendTests=true
# ─── 3. Runtime ──────────────────────────────────
FROM eclipse-temurin:21-jre-alpine
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 droideparanoico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+99 -33
View File
@@ -1,17 +1,109 @@
# Dostalgia
# DOStalgia 🕹️
A nostalgic DOS game hub. Upload your old DOS games, scrape artwork, and play them directly in the browser via js-dos (WebAssembly DOSBox).
A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and metadata from IGDB, and play them directly in your browser via js-dos (DOSBox compiled to WebAssembly).
### Library view
![library](images/library.jpeg "Library")
### Game detail view
![game](images/game.jpeg "Game")
## Features
### 🎮 Play in the browser
Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs.
**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off.
### 📦 Handles any file structure
DOS games come in all shapes. DOStalgia handles them transparently:
- **Flattening** — If your ZIP has a single root directory (e.g. `doom/` with all files inside), the extractor flattens it so the game files sit at the bundle root. No extra nesting.
- **Subdirectory games** — If files are deeper, the autoexec automatically `cd`s to the right directory before launching the executable.
- **CD images** — Games shipped on CD-ROM often need the disc mounted. DOStalgia detects `.iso`, `.cue`, `.img`, `.ccd`, and `.bin` files, fixes broken CloneCD `.cue` references (where the referenced `.bin` is actually `.img`), and mounts them in both `dosbox.conf` and `jsdos.json`.
- **CD-only games** — If the archive contains only CD images with no executable, a CD-only bundle is created that mounts the disc and drops you at the DOS prompt.
- **Hardcoded paths** — `ConfigPatcher` scans game config files for hardcoded absolute paths (e.g. `C:\FALLOUT1\MASTER.DAT`) that broke after flattening, and rewrites them to relative paths.
Other solutions such as [RomM](https://www.romm.app/) generate raw ZIP bundles and require you to write your own `dosbox.conf` with manual `mount` and `imgmount` commands. DOStalgia automates all of this for you.
### 🔍 Smart executable detection
On upload, DOStalgia scans every `.exe`, `.com`, and `.bat` file and picks the best candidate as the main executable using a scoring system:
1. **Not an installer** — INSTALL/SETUP/CONFIG executables are deprioritised
2. **DOS executables** — Pure DOS apps are preferred over Windows ones
3. **Larger files** — Bigger executables are more likely to be the game
4. **Shallow depth** — Files closer to the root are preferred
5. **Self-extractor filtering** — PKZIP/PKSFX stubs are filtered out
All discovered executables are stored and available in the **Edit** page, where you can pick a different one via radio buttons.
Again, RomM requires you to manually specify the executable in a custom `dosbox.conf`, while DOStalgia detects and configures it for you.
> **⚠️ Cache note:** If you change the executable, the `.jsdos` bundle is patched in-place. Your browser may serve a cached version of the old bundle — if the game doesn't launch with the new executable, **hard-refresh** the play page (Ctrl+Shift+R / Cmd+Shift+R).
### 🛠 One-click Setup launcher
Many DOS games include a `SETUP.EXE`, `INSTALL.EXE`, or `CONFIG.EXE` used to configure sound, controls, and graphics. When DOStalgia detects one:
- A **🛠 Setup** button appears on the game detail page
- Clicking it launches the setup executable *without modifying the main game bundle*
- The setup bundle is generated **on-the-fly** by the server — a modified `.jsdos` is streamed with the setup executable in the autoexec, then discarded. Nothing is written to disk.
This is not supported in RomM, where you would have to run the setup manually from the DOS prompt every time.
### 🪟 Windows game detection
DOSBox can't run Windows executables. DOStalgia's `PlatformDetector` reads MZ/PE/NE headers to detect Windows executables:
- The game detail page shows a **🪟 Requires Windows 3.1** badge
- The **Play** button is disabled with "Unplayable" text
- If you click Play anyway, a warning overlay explains the limitation with a **Try anyway** fallback
- Setup executables that are Windows-native are also filtered out from the Setup button
### 📡 IGDB metadata & media
DOStalgia integrates with [IGDB](https://www.igdb.com/) (via Twitch OAuth2) to auto-populate game info:
- **Auto Scrape** — On upload, DOStalgia searches IGDB by title and fills in year, genre, developer, publisher, description, and cover art. If IGDB finds a DOS result, it prioritises it.
- **Manual Search** — From the game detail page you can search IGDB by any query, browse results with cover thumbnails and DOS badges, and apply the one you want.
- **Media** — Videos (YouTube embeds) and screenshots (1080p) are fetched and displayed in a scrollable media gallery with a preview player.
**Setup:**
1. Go to https://dev.twitch.tv/console/apps → **Register Your Application**
2. Name: `dostalgia` (or anything), OAuth Redirect URL: `http://localhost`, Category: **Other**
3. Copy the **Client ID** and generate a **Client Secret**
4. Provide them via environment variables:
Method | How
--- | ---
Docker run | `-e TWITCH_CLIENT_ID=xxx -e TWITCH_CLIENT_SECRET=yyy`
Docker Compose | Copy `.env.example` to `.env` and fill in the values
Local dev | `export TWITCH_CLIENT_ID=xxx TWITCH_CLIENT_SECRET=yyy`
IGDB features degrade gracefully — if credentials aren't set, uploads and metadata editing still work, just without auto-scrape.
## Quick Start
### With Docker Compose (easiest)
```bash
# Build and run with Docker
docker build -t dostalgia .
docker run -p 8765:8765 -v $(pwd)/data:/data dostalgia
# 1. (Optional) Enable IGDB metadata auto-scrape
cp .env.example .env
# Edit .env with your Twitch credentials (see IGDB section below)
# 2. Pull & run
docker compose up -d
```
Open http://localhost:8765
### With Docker
```bash
docker run -p 8765:8765 -v $(pwd)/data:/data \
-e TWITCH_CLIENT_ID=your_id \
-e TWITCH_CLIENT_SECRET=your_secret \
droideparanoico/dostalgia
```
## Development (without Docker)
Terminal 1 — Frontend dev server:
@@ -35,34 +127,8 @@ java -jar target/quarkus-app/quarkus-run.jar
## Architecture
- **Backend**: Quarkus (Java 21, JAX-RS) — ~5 REST resource classes, zero database
- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external 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
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/games` | List all games |
| GET | `/api/games/:id` | Get game details |
| PATCH | `/api/games/:id` | Update game metadata |
| DELETE | `/api/games/:id` | Delete a game |
| POST | `/api/upload` | Upload a game ZIP (multipart) |
| POST | `/api/games/:id/cover` | Upload cover art |
| GET | `/api/igdb/search?q=` | Search IGDB (placeholder) |
## Game JSON Schema
```json
{
"id": "doom",
"title": "Doom",
"year": 1993,
"genre": "FPS",
"developer": "id Software",
"bundle_file": "doom.jsdos",
"has_cover": true,
"ready": true
}
```
- **Saves**: Browser localStorage / indexedDB (managed by js-dos)
+6 -6
View File
@@ -1,14 +1,14 @@
services:
dostalgia:
build: .
image: droideparanoico/dostalgia
container_name: dostalgia
restart: unless-stopped
ports:
- "8765:8765"
volumes:
# Configure here your data directory if you want persistence.
- ./data:/data
env_file:
- .env
environment:
- DOSTALGIA_DATA_DIR=/data
# IGDB credentials — set these for auto-scrape to work:
# - TWITCH_CLIENT_ID=your_client_id
# - TWITCH_CLIENT_SECRET=your_client_secret
restart: unless-stopped
- DOSTALGIA_DATA_DIR=/data
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect x='4' y='6' width='28' height='22' rx='2' fill='%2333FF33' opacity='.15'/><rect x='6' y='8' width='24' height='16' fill='%230A0A0A'/><rect x='8' y='10' width='20' height='10' fill='%2333FF33' opacity='.3'/><rect x='10' y='12' width='6' height='4' fill='%2333FF33' opacity='.6'/><rect x='18' y='12' width='6' height='4' fill='%2333FF33' opacity='.4'/></svg>" />
<title>Dostalgia</title>
<title>DOStalgia</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
+1874 -3
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,16 +1,21 @@
{
"name": "dostalgia-frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/svelte": "^5.3.1",
"jsdom": "^29.1.1",
"svelte": "^5.0.0",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vitest": "^4.1.8"
}
}
+1 -1
View File
@@ -43,7 +43,7 @@
});
</script>
<Header onUploadClick={() => uploadTriggered++} />
<Header onUploadClick={() => uploadTriggered++} hideUpload={route !== "/"} />
<main class="container">
{#if route === "/"}
+13 -8
View File
@@ -98,7 +98,8 @@ h3 { font-size: 1.2rem; }
/* ═══════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════ */
.btn {
/* Only targets DOStalgia buttons, not js-dos emulator buttons inside .dos-container */
.btn.btn:not(.dos-container .btn) {
display: inline-flex;
align-items: center;
gap: 8px;
@@ -111,32 +112,36 @@ h3 { font-size: 1.2rem; }
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-transform: none;
height: auto;
min-height: 0;
line-height: normal;
transition: all 0.15s ease;
}
.btn:hover {
.btn.btn:not(.dos-container .btn):hover {
background: var(--phosphor-burn);
border-color: var(--phosphor);
box-shadow: 0 0 12px var(--phosphor-dark);
}
.btn:active {
.btn.btn:not(.dos-container .btn):active {
transform: scale(0.97);
}
.btn-primary {
.btn-primary.btn-primary:not(.dos-container .btn) {
background: var(--phosphor-dark);
border-color: var(--phosphor);
color: var(--phosphor-glow);
}
.btn-primary:hover {
.btn-primary.btn-primary:not(.dos-container .btn):hover {
background: var(--phosphor-dim);
color: var(--bg);
}
.btn-danger {
.btn-danger.btn-danger:not(.dos-container .btn) {
border-color: #cc3333;
color: #cc3333;
}
.btn-danger:hover {
.btn-danger.btn-danger:not(.dos-container .btn):hover {
background: #331111;
border-color: #ff4444;
color: #ff4444;
@@ -187,7 +192,7 @@ input::placeholder {
@media (max-width: 640px) {
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
.btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
.btn.btn:not(.dos-container .btn) { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; }
input, textarea, select { font-size: 16px; } /* prevents iOS zoom */
}
+4 -2
View File
@@ -1,5 +1,5 @@
<script>
let { onUploadClick = () => {} } = $props();
let { onUploadClick = () => {}, hideUpload = false } = $props();
</script>
<header class="header">
@@ -17,7 +17,9 @@
<span class="logo-text">DOSTALGIA</span>
</a>
<nav>
<button class="btn" onclick={onUploadClick}>+ Upload</button>
{#if !hideUpload}
<button class="btn" onclick={onUploadClick}>+ Upload</button>
{/if}
</nav>
</div>
</header>
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/svelte";
import GameCard from "../GameCard.svelte";
function makeGame(overrides = {}) {
return {
id: "test-1",
title: "Test Game",
year: 1995,
genre: "Adventure",
platform: "dos",
has_cover: false,
...overrides,
};
}
describe("GameCard", () => {
it("renders the game title", () => {
render(GameCard, { props: { game: makeGame() } });
expect(screen.getByText("Test Game")).toBeTruthy();
});
it("renders the year when provided", () => {
render(GameCard, { props: { game: makeGame({ year: 1997 }) } });
expect(screen.getByText("1997")).toBeTruthy();
});
it("does not render year when absent", () => {
render(GameCard, { props: { game: makeGame({ year: null }) } });
expect(screen.queryByText("1995")).toBeNull();
});
it("renders the genre when provided", () => {
render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } });
expect(screen.getByText("RPG")).toBeTruthy();
});
it("shows a cover image when has_cover is true", () => {
const game = makeGame({ has_cover: true });
render(GameCard, { props: { game } });
const img = screen.getByRole("img");
expect(img).toBeTruthy();
expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`);
expect(img.getAttribute("alt")).toBe(game.title);
});
it("shows a placeholder when has_cover is false", () => {
render(GameCard, { props: { game: makeGame({ has_cover: false }) } });
expect(screen.getByText("💾")).toBeTruthy();
});
it("shows Windows badge for windows platform", () => {
render(GameCard, {
props: { game: makeGame({ platform: "windows" }) },
});
expect(screen.getByText(/🪟/)).toBeTruthy();
});
it("does not show Windows badge for DOS platform", () => {
render(GameCard, { props: { game: makeGame({ platform: "dos" }) } });
expect(screen.queryByText(/🪟/)).toBeNull();
});
it("navigates on click", async () => {
window.location.hash = "";
render(GameCard, { props: { game: makeGame() } });
const btn = screen.getByRole("button");
btn.click();
expect(window.location.hash).toBe("#/game/test-1");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js";
describe("artworkUrl", () => {
it("returns null for null input", () => {
expect(artworkUrl(null)).toBeNull();
});
it("returns null for undefined input", () => {
expect(artworkUrl(undefined)).toBeNull();
});
it("passes through absolute HTTP URLs", () => {
expect(artworkUrl("https://example.com/cover.jpg")).toBe(
"https://example.com/cover.jpg"
);
});
it("proxies relative paths through /artwork/", () => {
expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png");
});
it("preserves nested relative paths", () => {
expect(artworkUrl("some/deep/path/file.webp")).toBe(
"/artwork/some/deep/path/file.webp"
);
});
});
describe("bundleUrl", () => {
it("returns null for null game", () => {
expect(bundleUrl(null)).toBeNull();
});
it("returns null for undefined game", () => {
expect(bundleUrl(undefined)).toBeNull();
});
it("builds URL from bundle_file", () => {
expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip");
});
it("preserves subdirectory bundle paths", () => {
expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe(
"/games/subdir/game.zip"
);
});
});
describe("setupBundleUrl", () => {
it("returns null for null game", () => {
expect(setupBundleUrl(null)).toBeNull();
});
it("returns null for undefined game", () => {
expect(setupBundleUrl(undefined)).toBeNull();
});
it("builds API setup URL from game id", () => {
expect(setupBundleUrl({ id: "game-1" })).toBe(
"/api/games/game-1/setup-bundle"
);
});
it("handles games with numeric id", () => {
expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { push, replace } from "../router.js";
describe("router", () => {
describe("push", () => {
it("sets window.location.hash with # prefix", () => {
window.location.hash = "";
push("/game/123");
expect(window.location.hash).toBe("#/game/123");
});
it("handles root path", () => {
window.location.hash = "";
push("/");
expect(window.location.hash).toBe("#/");
});
it("replaces hash on subsequent calls", () => {
window.location.hash = "";
push("/first");
push("/second");
expect(window.location.hash).toBe("#/second");
});
});
describe("replace", () => {
it("sets hash via replace (no back-navigation allowed)", () => {
window.location.hash = "#/old";
replace("/new");
expect(window.location.hash).toBe("#/new");
});
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
/** API client for Dostalgia backend. */
/** API client for DOStalgia backend. */
const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev
+1 -9
View File
@@ -134,7 +134,7 @@
{isSetup ? "Back" : game.title}
</button>
{#if running}
<button class="btn stop-btn" onclick={stopEmulator}> Stop</button>
<button class="btn btn-danger" onclick={stopEmulator}> Stop</button>
{/if}
{/if}
</div>
@@ -211,14 +211,6 @@
font-family: var(--font-sans);
}
.link-button:hover { color: var(--phosphor); }
.stop-btn {
border-color: #cc3333;
color: #ff4444;
}
.stop-btn:hover {
background: #331111;
box-shadow: 0 0 12px #331111;
}
/* Emulator canvas — always mounted */
.dos-container {
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import GameDetail from "../GameDetail.svelte";
// Mock api.js
vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(),
deleteGame: vi.fn(),
updateGame: vi.fn(),
searchIGDB: vi.fn(),
scrapeIGDB: vi.fn(),
downloadGame: vi.fn(),
bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(),
artworkUrl: vi.fn(),
}));
// Mock router
vi.mock("../../lib/router.js", () => ({
push: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "game-1",
title: "Commander Keen",
year: 1991,
genre: "Platformer",
developer: "id Software",
publisher: "Apogee",
description: "A classic DOS platformer.",
platform: "dos",
ready: true,
has_cover: false,
has_setup: false,
bundle_file: "commander-keen.zip",
bundle_size: 512000,
executables: ["keen.exe"],
executable: null,
videos: [],
screenshots: [],
...overrides,
};
}
describe("GameDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
api.getGame.mockReturnValue(new Promise(() => {}));
render(GameDetail, { props: { id: "game-1" } });
expect(screen.getByText("Loading...")).toBeTruthy();
});
it("shows error when game is not found", async () => {
api.getGame.mockRejectedValue(new Error("Game not found"));
render(GameDetail, { props: { id: "missing" } });
await vi.waitFor(() => {
expect(screen.getByText("Game not found")).toBeTruthy();
});
});
it("renders game title", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Commander Keen")).toBeTruthy();
});
});
it("renders developer and publisher", async () => {
api.getGame.mockResolvedValue(
makeGame({ developer: "id Software", publisher: "Apogee" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText(/id Software/)).toBeTruthy();
expect(screen.getByText(/Apogee/)).toBeTruthy();
});
});
it("renders year and genre as links", async () => {
api.getGame.mockResolvedValue(
makeGame({ year: 1991, genre: "Platformer" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("1991").closest("a")).toBeTruthy();
expect(screen.getByText("Platformer").closest("a")).toBeTruthy();
});
});
it("shows DOS badge for dos platform", async () => {
api.getGame.mockResolvedValue(makeGame({ platform: "dos" }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("💾 DOS")).toBeTruthy();
});
});
it("shows Windows badge for windows platform", async () => {
api.getGame.mockResolvedValue(makeGame({ platform: "windows" }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText(/Windows 3\.1/)).toBeTruthy();
});
});
it("shows Play button for ready DOS games", async () => {
api.getGame.mockResolvedValue(
makeGame({ ready: true, platform: "dos" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("▶ Play")).toBeTruthy();
});
});
it("shows disabled Play button for Windows games", async () => {
api.getGame.mockResolvedValue(
makeGame({ ready: true, platform: "windows" })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("▶ Unplayable")).toBeTruthy();
});
});
it("shows Setup button when has_setup is true", async () => {
api.getGame.mockResolvedValue(
makeGame({ has_setup: true })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("🛠 Setup")).toBeTruthy();
});
});
it("shows Edit, Download, Delete buttons", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("✏ Edit")).toBeTruthy();
expect(screen.getByText("⬇ Download")).toBeTruthy();
expect(screen.getByText("✕ Delete")).toBeTruthy();
});
});
it("enters edit mode when Edit button is clicked", async () => {
api.getGame.mockResolvedValue(makeGame());
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("✏ Edit")).toBeTruthy();
});
screen.getByText("✏ Edit").click();
await vi.waitFor(() => {
expect(screen.getByText("Save")).toBeTruthy();
expect(screen.getByText("Cancel")).toBeTruthy();
});
});
it("shows cover placeholder when no cover", async () => {
api.getGame.mockResolvedValue(makeGame({ has_cover: false }));
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("💾")).toBeTruthy();
});
});
it("shows description when provided", async () => {
api.getGame.mockResolvedValue(
makeGame({ description: "A classic DOS platformer." })
);
render(GameDetail, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(
screen.getByText("A classic DOS platformer.")
).toBeTruthy();
});
});
});
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import Library from "../Library.svelte";
// Mock the API module that Library imports
vi.mock("../../lib/api.js", () => ({
listGames: vi.fn(),
uploadGame: vi.fn(),
searchIGDB: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "g1",
title: "Test Game",
year: 1995,
genre: "Adventure",
platform: "dos",
has_cover: false,
...overrides,
};
}
describe("Library", () => {
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = "#/";
});
it("shows loading state initially", () => {
api.listGames.mockReturnValue(new Promise(() => {})); // never resolves
render(Library);
expect(screen.getByText("Scanning library...")).toBeTruthy();
});
it("shows empty state when no games", async () => {
api.listGames.mockResolvedValue({ games: [] });
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("No games yet")).toBeTruthy();
});
});
it("renders game cards when games load", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "Game One" }),
makeGame({ id: "g2", title: "Game Two" }),
],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("Game One")).toBeTruthy();
expect(screen.getByText("Game Two")).toBeTruthy();
});
});
it("shows game count", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "One" }),
makeGame({ id: "g2", title: "Two" }),
],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("2 games")).toBeTruthy();
});
});
it("shows singular game count for one game", async () => {
api.listGames.mockResolvedValue({
games: [makeGame({ id: "g1", title: "Only" })],
});
render(Library);
await vi.waitFor(() => {
expect(screen.getByText("1 game")).toBeTruthy();
});
});
describe("filters", () => {
it("shows filter options derived from loaded games", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", genre: "RPG", year: 1997 }),
makeGame({ id: "g2", genre: "RPG", year: 1998 }),
makeGame({ id: "g3", genre: "Adventure", year: 1995 }),
],
});
render(Library);
await vi.waitFor(() => {
// Year labels appear both as filter options and in game cards
expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1);
});
});
it("applies genre filter from props", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }),
makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }),
],
});
render(Library, { props: { genreFilter: "RPG" } });
await vi.waitFor(() => {
expect(screen.getByText("RPG Game")).toBeTruthy();
expect(screen.queryByText("Adventure Game")).toBeNull();
});
});
it("applies year filter from props", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", title: "Old Game", year: 1993 }),
makeGame({ id: "g2", title: "New Game", year: 1997 }),
],
});
render(Library, { props: { yearFilter: "1997" } });
await vi.waitFor(() => {
expect(screen.getByText("New Game")).toBeTruthy();
expect(screen.queryByText("Old Game")).toBeNull();
});
});
it("shows filter-empty message when no filter options exist", async () => {
api.listGames.mockResolvedValue({
games: [
makeGame({ id: "g1", genre: null, year: null }),
makeGame({ id: "g2", genre: null, year: null }),
],
});
render(Library);
await vi.waitFor(() => {
const dashes = screen.getAllByText("—");
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import Play from "../Play.svelte";
// Mock the API module that Play imports
vi.mock("../../lib/api.js", () => ({
getGame: vi.fn(),
bundleUrl: vi.fn(),
setupBundleUrl: vi.fn(),
}));
// Mock router
vi.mock("../../lib/router.js", () => ({
push: vi.fn(),
}));
import * as api from "../../lib/api.js";
function makeGame(overrides = {}) {
return {
id: "game-1",
title: "Commander Keen",
platform: "dos",
ready: true,
...overrides,
};
}
describe("Play", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows loading state initially", () => {
api.getGame.mockReturnValue(new Promise(() => {}));
render(Play, { props: { id: "game-1" } });
expect(screen.getByText("Loading game data...")).toBeTruthy();
});
it("shows not-ready state when game is not processed", async () => {
api.getGame.mockResolvedValue(makeGame({ ready: false }));
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Not ready")).toBeTruthy();
});
});
it("shows unsupported warning for Windows games", async () => {
api.getGame.mockResolvedValue(
makeGame({ platform: "windows", ready: true })
);
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Windows game")).toBeTruthy();
});
});
it("shows error box when game fetch fails", async () => {
api.getGame.mockRejectedValue(new Error("Game not found"));
render(Play, { props: { id: "missing" } });
// The error should appear in the overlay as well
await vi.waitFor(() => {
expect(screen.getByText("⚠️")).toBeTruthy();
});
});
it("shows emulator booting state for ready DOS games", async () => {
api.getGame.mockResolvedValue(
makeGame({ platform: "dos", ready: true })
);
api.bundleUrl.mockReturnValue("/games/game-1.jsdos");
render(Play, { props: { id: "game-1" } });
await vi.waitFor(() => {
expect(screen.getByText("Starting emulator...")).toBeTruthy();
});
});
});
+9
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
@@ -15,4 +16,12 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
},
resolve: {
conditions: process.env.VITEST ? ["browser", "module", "import"] : [],
},
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.js"],
},
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

+55 -2
View File
@@ -4,9 +4,9 @@
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>
<groupId>org.dostalgia</groupId>
<artifactId>dostalgia</artifactId>
<version>0.1.0</version>
<version>0.1.1</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
@@ -15,6 +15,10 @@
<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>
<!-- Set to true to skip all frontend npm tasks (install/build/test) -->
<skipFrontend>false</skipFrontend>
<!-- Set to true to skip only frontend tests (backend tests still run) -->
<skipFrontendTests>false</skipFrontendTests>
</properties>
<dependencyManagement>
@@ -97,6 +101,55 @@
</execution>
</executions>
</plugin>
<!-- Run frontend npm tasks during the Maven lifecycle -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>npm-install</id>
<phase>initialize</phase>
<goals><goal>exec</goal></goals>
<configuration>
<workingDirectory>frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>ci</argument>
<argument>--prefer-offline</argument>
</arguments>
<skip>${skipFrontend}</skip>
</configuration>
</execution>
<execution>
<id>frontend-build</id>
<phase>generate-resources</phase>
<goals><goal>exec</goal></goals>
<configuration>
<workingDirectory>frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>run</argument>
<argument>build</argument>
</arguments>
<skip>${skipFrontend}</skip>
</configuration>
</execution>
<execution>
<id>frontend-test</id>
<phase>test</phase>
<goals><goal>exec</goal></goals>
<configuration>
<workingDirectory>frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>test</argument>
</arguments>
<skip>${skipFrontendTests}</skip>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,67 +0,0 @@
package com.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
/**
* Detects platform type (DOS vs Windows) by reading executable headers.
* Pure logic — no I/O beyond reading the file header bytes.
*/
@ApplicationScoped
public class PlatformDetector {
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
/**
* Detect whether an executable targets DOS or Windows by reading its PE/NE header.
* <ul>
* <li>Pure MZ, LE (DOS/4GW extender), LX (OS/2) → "dos"</li>
* <li>NE (Windows 3.x), PE (Win32) → "windows"</li>
* <li>Cannot read → null</li>
* </ul>
*/
public String detect(Path exePath) {
String name = exePath.getFileName().toString().toLowerCase();
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
try (var fis = Files.newInputStream(exePath)) {
byte[] buf = new byte[0x80];
int read = fis.readNBytes(buf, 0, buf.length);
if (read < 2) return null;
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
if (read < 0x40) return "dos";
// Read e_lfanew at offset 0x3C
int peOffset = (buf[0x3C] & 0xFF)
| ((buf[0x3D] & 0xFF) << 8)
| ((buf[0x3E] & 0xFF) << 16)
| ((buf[0x3F] & 0xFF) << 24);
if (peOffset < 0) return "dos";
byte sig1, sig2;
if (peOffset + 2 <= read) {
sig1 = buf[peOffset];
sig2 = buf[peOffset + 1];
} else {
try (var fis2 = Files.newInputStream(exePath)) {
sig1 = (byte) fis2.read();
sig2 = (byte) fis2.read();
}
}
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
return "windows";
return "dos";
} catch (IOException e) {
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
return null;
}
}
}
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
@@ -51,7 +51,7 @@ public class ConfigBuilder {
/** Build jsdos.json for a CD-only game. */
public byte[] buildCdOnlyJsdosJson(List<String> cdImages) {
String script = buildCdOnlyAutoexecScript(cdImages);
String script = buildCdMountScript(cdImages) + "c:\n";
return buildJsdosJsonRaw(script);
}
@@ -188,7 +188,7 @@ public class ConfigBuilder {
return "path=c:\\\ncd " + dir + "\n" + exe + "\n";
}
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
private static String buildCdMountScript(List<String> cdImages) {
StringBuilder script = new StringBuilder();
script.append("mount c .\n");
char driveLetter = 'D';
@@ -200,6 +200,11 @@ public class ConfigBuilder {
.append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
return script.toString();
}
private static String buildAutoexecScript(String dir, String exe, List<String> cdImages) {
StringBuilder script = new StringBuilder(buildCdMountScript(cdImages));
script.append("c:\n");
if (dir != null) {
script.append("path=c:\\\n");
@@ -209,22 +214,6 @@ public class ConfigBuilder {
return script.toString();
}
private static String buildCdOnlyAutoexecScript(List<String> cdImages) {
StringBuilder script = new StringBuilder();
script.append("mount c .\n");
char driveLetter = 'D';
for (String img : cdImages) {
String imgLower = img.toLowerCase();
String flags = imgLower.endsWith(".bin") && !imgLower.endsWith(".cue")
? " -t cdrom -fs iso" : " -t cdrom";
script.append("imgmount ").append(driveLetter).append(" \"")
.append(img).append("\"").append(flags).append("\n");
driveLetter++;
}
script.append("c:\n");
return script.toString();
}
private static byte[] buildJsdosJsonRaw(String script) {
String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\""
+ escapeJson(script) + "\"}}},"
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
@@ -1,5 +1,6 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.annotation.Nonnull;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -24,7 +25,6 @@ public class ExecutableDetector {
@Inject
PlatformDetector platform;
/** Executable stems to never select as main game executable. */
private static final Set<String> SKIP_EXE_NAMES = Set.of(
"dos4gw", "dos32a", "dos4gw2", "pmode", "pmodew", "cwsdpmi",
"emm386", "himem", "himemx", "debug",
@@ -35,31 +35,17 @@ public class ExecutableDetector {
"ace", "unace", "zoo", "arc", "ha", "cab"
);
/** Collect all discoverable executables (relative paths) from the extracted directory. */
public List<String> findAll(Path dir) throws IOException {
List<String> result = 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")) {
result.add(dir.relativize(f).toString().replace('\\', '/'));
}
return FileVisitResult.CONTINUE;
}
});
result.sort(String::compareTo);
return result;
return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
}
/** Find the best main executable. */
public String findMain(Path dir) throws IOException {
record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {}
List<Candidate> candidates = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE;
@@ -95,23 +81,23 @@ public class ExecutableDetector {
return candidates.getFirst().path();
}
/** Find a setup/install/config executable. */
public String findSetup(Path dir) throws IOException {
record Candidate(int depth, long size, String path) {}
record Candidate(int depth, boolean isWindows, long size, String path) {}
List<Candidate> candidates = new ArrayList<>();
List<String> patterns = List.of("setup", "install", "config", "custom");
List<String> patterns = List.of("setup", "setmain", "install", "config", "custom");
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name;
if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat"))
return FileVisitResult.CONTINUE;
for (String pat : patterns) {
if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) {
boolean isWin = "windows".equals(platform.detect(f));
candidates.add(new Candidate(
f.getNameCount() - dir.getNameCount(), a.size(), f.toString()));
f.getNameCount() - dir.getNameCount(), isWin, a.size(), f.toString()));
break;
}
}
@@ -120,11 +106,13 @@ public class ExecutableDetector {
});
if (candidates.isEmpty()) return null;
candidates.sort(Comparator.comparingInt(Candidate::depth).thenComparingLong(Candidate::size));
candidates.sort(Comparator
.comparingInt((Candidate c) -> c.isWindows() ? 1 : 0) // prefer DOS over Windows
.thenComparingInt(Candidate::depth)
.thenComparingLong(Candidate::size));
return candidates.getFirst().path();
}
/** Check if an executable is likely a self-extracting archive (PKSFX stub, etc.). */
public static boolean isLikelySelfExtractor(Path exePath) {
try (var fis = Files.newInputStream(exePath)) {
byte[] buf = new byte[32768];
@@ -0,0 +1,60 @@
package org.dostalgia;
import jakarta.annotation.Nonnull;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/** Shared file-system utilities. */
public final class FileUtils {
private FileUtils() {}
/** Recursively delete a directory tree (walk in reverse order so dirs are empty when deleted). */
public static void deleteDirectory(final Path dir) throws IOException {
if (!Files.exists(dir)) return;
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public @Nonnull FileVisitResult visitFile(@Nonnull final Path f, @Nonnull final BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override
public @Nonnull FileVisitResult postVisitDirectory(@Nonnull final Path d, final IOException e) throws IOException {
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});
}
/**
* Walk a directory tree and collect relative paths of files whose extension matches
* one of the given extensions. Extensions should include the dot, e.g. {@code ".exe"}.
* Results are sorted alphabetically.
*/
public static List<String> findFilesByExtensions(Path dir, Set<String> extensions) throws IOException {
List<String> result = new ArrayList<>();
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) {
String name = f.getFileName().toString().toLowerCase();
for (String ext : extensions) {
if (name.endsWith(ext)) {
result.add(dir.relativize(f).toString().replace('\\', '/'));
break;
}
}
return FileVisitResult.CONTINUE;
}
});
result.sort(String::compareTo);
return result;
}
}
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
@@ -15,7 +15,7 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import java.nio.file.NoSuchFileException;
import java.nio.file.Files;
import java.util.*;
import java.util.Map;
@Path("/api/games")
@Produces(MediaType.APPLICATION_JSON)
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -12,13 +12,10 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
@@ -80,9 +77,12 @@ public class GameService implements GameStore {
);
// Detect bundle size
try {
Path bundlePath = gamesDir.resolve(game.getBundleFile());
if (Files.exists(bundlePath)) {
game.setBundleSize(Files.size(bundlePath));
String bf = game.getBundleFile();
if (bf != null) {
Path bundlePath = gamesDir.resolve(bf);
if (Files.exists(bundlePath)) {
game.setBundleSize(Files.size(bundlePath));
}
}
} catch (IOException ignored) {}
return game;
@@ -121,21 +121,7 @@ public class GameService implements GameStore {
/** Delete a game and all its files. */
public void delete(String id) throws IOException {
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;
}
});
}
FileUtils.deleteDirectory(gameDir(id));
// Clean up old flat .jsdos locations (pre-game-dir layout backward compat)
Files.deleteIfExists(gamesDir.resolve(id + ".jsdos"));
Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos"));
@@ -225,18 +211,7 @@ public class GameService implements GameStore {
java.util.zip.ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals(relExe.replace('\\', '/'))) {
byte[] buf = new byte[0x80];
int read = zis.readNBytes(buf, 0, buf.length);
if (read < 2) return null;
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
if (read < 0x40) return "dos";
int peOffset = (buf[0x3C] & 0xFF) | ((buf[0x3D] & 0xFF) << 8)
| ((buf[0x3E] & 0xFF) << 16) | ((buf[0x3F] & 0xFF) << 24);
if (peOffset < 0 || peOffset + 2 > read) return "dos";
byte sig1 = buf[peOffset], sig2 = buf[peOffset + 1];
if ((sig1 == 0x50 && sig2 == 0x45) || (sig1 == 0x4E && sig2 == 0x45))
return "windows";
return "dos";
return PlatformDetector.detectFromBytes(PlatformDetector.readHeaderBytes(zis));
}
zis.closeEntry();
}
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import java.io.IOException;
import java.nio.file.Path;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -54,8 +54,10 @@ public class IgdbService {
if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) {
return accessToken;
}
String body = "client_id=" + clientId.get()
+ "&client_secret=" + clientSecret.get()
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured"));
String body = "client_id=" + cid
+ "&client_secret=" + secret
+ "&grant_type=client_credentials";
HttpRequest req = HttpRequest.newBuilder()
@@ -72,27 +74,34 @@ public class IgdbService {
JsonNode json = mapper.readTree(res.body());
accessToken = json.get("access_token").asText();
int expiresIn = json.get("expires_in").asInt();
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); // 2 min buffer
tokenExpiry = Instant.now().plusSeconds(expiresIn - 120);
LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)");
return accessToken;
}
private HttpRequest.Builder igdbRequest(String path) throws Exception {
String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured"));
return HttpRequest.newBuilder()
.uri(URI.create(IGDB_API + path))
.header("Client-ID", clientId.get())
.header("Client-ID", cid)
.header("Authorization", "Bearer " + getToken())
.header("Content-Type", "text/plain");
}
/**
* Search IGDB for games matching the given query.
* Returns a list of simplified result maps suitable for JSON serialization.
*/
/** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */
private JsonNode postAndGet(String endpoint, String apql) throws Exception {
HttpRequest req = igdbRequest(endpoint)
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) return null;
JsonNode results = mapper.readTree(res.body());
return results.isArray() ? results : null;
}
public List<Map<String, Object>> search(String query) throws Exception {
if (!isConfigured()) return List.of();
// Search limited to DOS platform (IGDB platform ID 13)
String apql = "search \"" + sanitize(query) + "\";"
+ " fields name,first_release_date,genres.name,"
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
@@ -100,19 +109,9 @@ public class IgdbService {
+ " where platforms = [13];"
+ " limit 20;";
HttpRequest req = igdbRequest("/games")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
LOG.warning("IGDB search failed: HTTP " + res.statusCode() + " " + res.body());
return List.of();
}
JsonNode results = mapper.readTree(res.body());
if (!results.isArray() || results.isEmpty()) {
LOG.info("IGDB search: no results for '" + query + "'. Response: " + res.body());
JsonNode results = postAndGet("/games", apql);
if (results == null || results.isEmpty()) {
LOG.info("IGDB search: no results for '" + query + "'");
return List.of();
}
@@ -122,22 +121,13 @@ public class IgdbService {
entry.put("igdb_id", game.get("id").asInt());
entry.put("name", game.has("name") ? game.get("name").asText() : query);
if (game.has("first_release_date")) {
long epoch = game.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
entry.put("year", cal.get(Calendar.YEAR));
}
epochToYear(game).ifPresent(y -> entry.put("year", y));
if (game.has("genres") && game.get("genres").isArray()) {
List<String> genres = new ArrayList<>();
for (JsonNode g : game.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
List<String> genres = extractGenres(game);
if (!genres.isEmpty()) {
entry.put("genres", genres);
}
// Extract developer and publisher
if (game.has("involved_companies") && game.get("involved_companies").isArray()) {
String developer = null;
String publisher = null;
@@ -158,216 +148,104 @@ public class IgdbService {
entry.put("summary", game.get("summary").asText());
}
// Platforms (check if DOS or PC)
if (game.has("platforms") && game.get("platforms").isArray()) {
List<Integer> platformIds = new ArrayList<>();
for (JsonNode p : game.get("platforms")) {
platformIds.add(p.asInt());
}
entry.put("platform_ids", platformIds);
boolean isDos = platformIds.contains(13); // 13 = DOS
entry.put("is_dos", isDos);
entry.put("is_dos", platformIds.contains(13));
}
// Cover URL IGDB now returns cover as object with url (since we use cover.url in fields)
if (game.has("cover") && game.get("cover").has("url")) {
String thumbUrl = game.get("cover").get("url").asText();
// IGDB URL format: //images.igdb.com/igdb/image/upload/t_thumb/co1x8h.jpg
// Replace t_thumb with our desired size
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
entry.put("cover_url", coverUrl);
} else if (game.has("cover") && game.get("cover").isInt()) {
// Fallback: cover is just an integer ID, fetch URL separately
int coverId = game.get("cover").asInt();
String coverUrl = fetchCoverUrl(coverId);
if (coverUrl != null) {
entry.put("cover_url", coverUrl);
}
if (coverUrl != null) entry.put("cover_url", coverUrl);
}
out.add(entry);
}
return out;
}
/**
* Fetch the cover image URL for a given IGDB cover ID.
*/
private String fetchCoverUrl(int coverId) throws Exception {
String apql = "fields url; where id = " + coverId + ";";
HttpRequest req = igdbRequest("/covers")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) return null;
JsonNode results = mapper.readTree(res.body());
if (!results.isArray() || results.isEmpty()) return null;
JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";");
if (results == null || results.isEmpty()) return null;
String thumbUrl = results.get(0).get("url").asText();
// IGDB returns: //images.igdb.com/.../t_thumb/xxx.jpg
// Replace t_thumb with desired size
return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1);
}
/**
* Fetch YouTube video IDs associated with an IGDB game.
*/
public List<String> fetchVideos(int igdbId) throws Exception {
String apql = "fields video_id; where game = " + igdbId + ";";
HttpRequest req = igdbRequest("/game_videos")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) return List.of();
JsonNode results = mapper.readTree(res.body());
if (!results.isArray()) return List.of();
private List<String> fetchVideos(int igdbId) throws Exception {
JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";");
if (results == null) return List.of();
List<String> videos = new ArrayList<>();
for (JsonNode v : results) {
if (v.has("video_id")) {
videos.add(v.get("video_id").asText());
}
if (v.has("video_id")) videos.add(v.get("video_id").asText());
}
return videos;
}
/**
* Fetch screenshot image URLs for an IGDB game.
*/
public List<String> fetchScreenshots(int igdbId) throws Exception {
String apql = "fields url; where game = " + igdbId + ";";
HttpRequest req = igdbRequest("/screenshots")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) return List.of();
JsonNode results = mapper.readTree(res.body());
if (!results.isArray()) return List.of();
private List<String> fetchScreenshots(int igdbId) throws Exception {
JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";");
if (results == null) return List.of();
List<String> screenshots = new ArrayList<>();
for (JsonNode s : results) {
if (s.has("url")) {
String thumbUrl = s.get("url").asText();
// Use bigger size for display
String fullUrl = "https:" + thumbUrl.replace("t_thumb", "t_1080p");
String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p");
screenshots.add(fullUrl);
}
}
return screenshots;
}
/**
* Auto-search IGDB for the game title and apply the best match.
* Updates the Game object in-place. Does NOT save caller must svc.save().
* Does NOT throw on failure; logs warnings instead.
*/
public void autoScrape(Game game) {
if (!isConfigured()) return;
try {
List<Map<String, Object>> results = search(game.getTitle());
if (results.isEmpty()) {
LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'");
return;
}
// Pick best match: prefer DOS platform, otherwise first result
Map<String, Object> best = null;
for (Map<String, Object> r : results) {
if (Boolean.TRUE.equals(r.get("is_dos"))) {
best = r;
break;
}
if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; }
}
if (best == null) best = results.get(0);
if (best == null) best = results.getFirst();
applyMatch(game, best);
LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'");
// Fetch videos and screenshots
Object igdbIdObj = best.get("igdb_id");
if (igdbIdObj instanceof Number igdbIdNum) {
int igdbId = igdbIdNum.intValue();
try {
List<String> videos = fetchVideos(igdbId);
if (!videos.isEmpty()) {
game.setVideos(videos);
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch videos: " + e.getMessage());
}
try {
List<String> screenshots = fetchScreenshots(igdbId);
if (!screenshots.isEmpty()) {
game.setScreenshots(screenshots);
game.setHasScreenshots(true);
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage());
}
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); }
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); }
}
} catch (Exception e) {
LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage());
}
}
/**
* Apply a specific IGDB ID to a game. Fetches fresh data and downloads cover.
*/
public void applyIgdbId(Game game, int igdbId) throws Exception {
// Fetch game details from IGDB by ID
String apql = "fields name,first_release_date,genres.name,"
+ " involved_companies.company.name,involved_companies.developer,involved_companies.publisher,"
+ " summary,cover.url;"
+ " where id = " + igdbId + ";";
HttpRequest req = igdbRequest("/games")
.POST(HttpRequest.BodyPublishers.ofString(apql))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new RuntimeException("IGDB fetch failed: HTTP " + res.statusCode());
}
JsonNode results = mapper.readTree(res.body());
if (!results.isArray() || results.isEmpty()) {
JsonNode results = postAndGet("/games", apql);
if (results == null || results.isEmpty()) {
throw new RuntimeException("IGDB: no game found with ID " + igdbId);
}
applyMatch(game, results.get(0));
// Fetch videos and screenshots
try {
List<String> videos = fetchVideos(igdbId);
if (!videos.isEmpty()) {
game.setVideos(videos);
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage());
}
try {
List<String> screenshots = fetchScreenshots(igdbId);
if (!screenshots.isEmpty()) {
game.setScreenshots(screenshots);
game.setHasScreenshots(true);
}
} catch (Exception e) {
LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage());
}
try { List<String> v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); }
catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); }
try { List<String> s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } }
catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); }
}
/**
* Apply IGDB game data to a local Game object. Downloads cover if available.
*/
@SuppressWarnings("unchecked")
private void applyMatch(Game game, Object matchData) throws Exception {
Map<String, Object> data;
@@ -378,56 +256,26 @@ public class IgdbService {
} else {
return;
}
// Update game fields
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) {
if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank()))
game.setTitle((String) data.get("name"));
}
if (data.containsKey("year")) {
game.setYear((Integer) data.get("year"));
}
if (data.containsKey("developer")) {
game.setDeveloper((String) data.get("developer"));
}
if (data.containsKey("publisher")) {
game.setPublisher((String) data.get("publisher"));
}
if (data.containsKey("summary")) {
game.setDescription((String) data.get("summary"));
}
if (data.containsKey("year")) game.setYear((Integer) data.get("year"));
if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer"));
if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher"));
if (data.containsKey("summary")) game.setDescription((String) data.get("summary"));
if (data.containsKey("genres")) {
List<String> genres = (List<String>) data.get("genres");
if (!genres.isEmpty()) {
game.setGenre(genres.getFirst());
}
if (!genres.isEmpty()) game.setGenre(genres.getFirst());
}
if (data.containsKey("igdb_id")) {
game.setIgdbId((Integer) data.get("igdb_id"));
}
// Download cover
if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id"));
String coverUrl = (String) data.get("cover_url");
if (coverUrl != null && !coverUrl.isBlank()) {
downloadCover(game, coverUrl);
}
if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl);
}
/**
* Download a cover image from IGDB CDN and save locally.
*/
private void downloadCover(Game game, String coverUrl) throws Exception {
// Ensure full URL (IGDB sometimes omits https:)
String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<Path> res = http.send(req, HttpResponse.BodyHandlers.ofFile(
gameStore.gameDir(game.getId()).resolve("cover.jpg")
));
gameStore.gameDir(game.getId()).resolve("cover.jpg")));
if (res.statusCode() == 200) {
game.setHasCover(true);
LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'");
@@ -436,6 +284,28 @@ public class IgdbService {
}
}
/** Convert IGDB epoch (seconds) to year, from 'first_release_date' field. */
private static Optional<Integer> epochToYear(JsonNode node) {
if (node.has("first_release_date")) {
long epoch = node.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
return Optional.of(cal.get(Calendar.YEAR));
}
return Optional.empty();
}
/** Extract genre names from a JsonNode that may have a 'genres' array. */
private static List<String> extractGenres(JsonNode node) {
List<String> genres = new ArrayList<>();
if (node.has("genres") && node.get("genres").isArray()) {
for (JsonNode g : node.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
}
return genres;
}
private String sanitize(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
@@ -444,22 +314,12 @@ public class IgdbService {
Map<String, Object> map = new LinkedHashMap<>();
map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0);
map.put("name", node.has("name") ? node.get("name").asText() : "");
epochToYear(node).ifPresent(y -> map.put("year", y));
if (node.has("first_release_date")) {
long epoch = node.get("first_release_date").asLong();
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(epoch * 1000);
map.put("year", cal.get(Calendar.YEAR));
}
if (node.has("genres") && node.get("genres").isArray()) {
List<String> genres = new ArrayList<>();
for (JsonNode g : node.get("genres")) {
if (g.has("name")) genres.add(g.get("name").asText());
}
List<String> genres = extractGenres(node);
if (!genres.isEmpty()) {
map.put("genres", genres);
}
if (node.has("involved_companies") && node.get("involved_companies").isArray()) {
for (JsonNode ic : node.get("involved_companies")) {
if (ic.has("company") && ic.get("company").has("name")) {
@@ -471,18 +331,12 @@ public class IgdbService {
}
}
}
if (node.has("summary")) {
map.put("summary", node.get("summary").asText());
}
// Fetch cover URL handle cover.url object or raw ID
if (node.has("summary")) map.put("summary", node.get("summary").asText());
if (node.has("cover")) {
JsonNode cover = node.get("cover");
try {
if (cover.has("url")) {
String thumbUrl = cover.get("url").asText();
String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE);
String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE);
map.put("cover_url", coverUrl);
} else if (cover.isInt() || cover.isLong()) {
String coverUrl = fetchCoverUrl(cover.asInt());
@@ -493,7 +347,6 @@ public class IgdbService {
}
} catch (Exception ignored) {}
}
return map;
}
}
@@ -0,0 +1,65 @@
package org.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
/**
* Detects platform type (DOS vs Windows) by reading executable headers.
*/
@ApplicationScoped
public class PlatformDetector {
private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName());
public String detect(Path exePath) {
String name = exePath.getFileName().toString().toLowerCase();
if (name.endsWith(".com") || name.endsWith(".bat")) return "dos";
try (var fis = Files.newInputStream(exePath)) {
return detectFromBytes(readHeaderBytes(fis));
} catch (IOException e) {
LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage());
return null;
}
}
/** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */
static byte[] readHeaderBytes(InputStream is) throws IOException {
byte[] buf = new byte[0x1000];
int read = is.readNBytes(buf, 0, buf.length);
return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf;
}
/** Detect platform from pre-read MZ/PE header bytes. */
static String detectFromBytes(byte[] buf) {
return detectFromBytes(buf, buf.length);
}
/** Detect platform from pre-read MZ/PE header bytes with explicit read length. */
static String detectFromBytes(byte[] buf, int read) {
if (read < 2) return null;
if (buf[0] != 0x4D || buf[1] != 0x5A) return null;
if (read < 0x40) return "dos";
int peOffset = (buf[0x3C] & 0xFF)
| ((buf[0x3D] & 0xFF) << 8)
| ((buf[0x3E] & 0xFF) << 16)
| ((buf[0x3F] & 0xFF) << 24);
if (peOffset < 0 || peOffset + 2 > read) return "dos";
byte sig1 = buf[peOffset];
byte sig2 = buf[peOffset + 1];
if ((sig1 == 0x50 && sig2 == 0x45) // PE
|| (sig1 == 0x4E && sig2 == 0x45)) // NE
return "windows";
return "dos";
}
}
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PathParam;
@@ -20,49 +20,40 @@ public class StaticResource {
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
String dataDir;
@GET
@jakarta.ws.rs.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();
/** Serve a file from a subdirectory of the data dir with path traversal protection. */
private Response serveFile(Path base, String path, String cacheHeader) {
Path file = base.resolve(path).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();
var builder = Response.ok(new FileStream(file)).type(contentType);
if (cacheHeader != null) {
builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim());
}
return builder.build();
}
@GET
@jakarta.ws.rs.Path("/games/{path: .*}")
public Response getGameFile(@PathParam("path") String path) {
return serveFile(Path.of(dataDir, "games"), path, "Accept-Ranges: bytes");
}
@GET
@jakarta.ws.rs.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();
return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400");
}
/** Health check */
@GET
@jakarta.ws.rs.Path("/api/health")
public Response health() {
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
return Response.ok(Map.of("status", "ok", "version", "0.1.1")).build();
}
private String guessContentType(String name) {
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
@@ -11,12 +11,9 @@ import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
@@ -139,7 +136,8 @@ public class UploadResource {
zip.createBundle(extractDir, mainExe, cdImages, bundlePath);
// Detect platform (DOS vs Windows)
String platformType = mainExe != null ? platform.detect(Path.of(mainExe)) : "dos";
Path mainExePath = mainExe != null ? Path.of(mainExe) : null;
String platformType = mainExe != null ? platform.detect(mainExePath) : "dos";
// Save metadata
Game game = new Game(gameId, title);
@@ -148,14 +146,15 @@ public class UploadResource {
// Store the selected executable and the full list for the UI picker
String relMain = mainExe != null
? extractDir.relativize(Path.of(mainExe)).toString().replace('\\', '/')
? extractDir.relativize(mainExePath).toString().replace('\\', '/')
: "";
game.setExecutable(relMain);
game.setExecutables(exeDetector.findAll(extractDir));
if (setupExe != null) {
String setupPlatform = platform.detect(Path.of(setupExe));
Path setupExePath = Path.of(setupExe);
String setupPlatform = platform.detect(setupExePath);
if (!"windows".equals(setupPlatform)) {
game.setSetupExe(extractDir.relativize(Path.of(setupExe)).toString());
game.setSetupExe(extractDir.relativize(setupExePath).toString());
}
}
game.setReady(true);
@@ -218,18 +217,6 @@ public class UploadResource {
}
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;
}
});
FileUtils.deleteDirectory(dir);
}
}
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -13,6 +13,8 @@ import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.logging.Logger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* ZIP extraction, directory flattening, and .jsdos bundle creation.
@@ -25,7 +27,6 @@ public class ZipService {
@Inject
ConfigBuilder config;
/** File extensions to exclude from .jsdos bundles (disk images DOSBox can't mount). */
private static final Set<String> SKIP_EXT = Set.of(
".nrg", ".mdf", ".mds", ".sub", ".dmg"
);
@@ -50,14 +51,13 @@ public class ZipService {
/**
* If the extraction directory contains a single subdirectory, move its contents up.
* Handles ZIPs where all game files are inside a folder.
*/
public void flattenSingleDir(Path dir) throws IOException {
Path singleDir = null;
try (var files = Files.list(dir)) {
for (Path entry : files.toList()) {
if (Files.isDirectory(entry)) {
if (singleDir != null) return; // multiple directories abort
if (singleDir != null) return;
singleDir = entry;
}
}
@@ -85,10 +85,8 @@ public class ZipService {
/**
* Create a .jsdos bundle ZIP from the extracted game directory.
* Generates DOSBox configs and packs everything into a valid js-dos bundle.
*/
public void createBundle(Path extractDir, String exePath, List<String> cdImages, Path bundlePath) throws IOException {
// Write .jsdos config into the extract directory
Path jsdos = extractDir.resolve(".jsdos");
Files.createDirectories(jsdos);
if (exePath != null) {
@@ -100,8 +98,7 @@ public class ZipService {
Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages));
}
// ZIP it up from extractDir
try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundlePath))) {
try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) {
// Phase 1: Collect all directory paths
var allDirs = new TreeSet<String>();
@@ -116,24 +113,16 @@ public class ZipService {
});
}
// Phase 2: Write directory entries first (sorted shallowest first)
// Phase 2: Write directory entries first
for (String dir : allDirs) {
zos.putNextEntry(new java.util.zip.ZipEntry(dir));
zos.putNextEntry(new ZipEntry(dir));
zos.closeEntry();
}
// Phase 3: .jsdos config files (must come before game files)
// Phase 3: .jsdos config files (before game files)
try (var walk = Files.walk(jsdos)) {
walk.filter(Files::isRegularFile).sorted().forEach(f -> {
try {
String entryName = extractDir.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);
}
});
walk.filter(Files::isRegularFile).sorted()
.forEach(f -> zipEntry(zos, extractDir, f));
}
// Phase 4: Game files (excluding skipped extensions)
@@ -146,22 +135,27 @@ public class ZipService {
return dot < 0 || !SKIP_EXT.contains(n.substring(dot));
})
.sorted()
.forEach(f -> {
try {
String entryName = extractDir.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);
}
});
.forEach(f -> zipEntry(zos, extractDir, f));
}
}
// Clean up .jsdos config files from the extract dir
Files.walk(jsdos).sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
try (var cleanup = Files.walk(jsdos)) {
cleanup.sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.deleteIfExists(p); } catch (IOException ignored) {}
});
}
}
/** Write a single file into a ZIP output stream, computing entry name relative to extractDir. */
private static void zipEntry(ZipOutputStream zos, Path extractDir, Path file) {
try {
String entryName = extractDir.relativize(file).toString().replace('\\', '/');
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(file, zos);
zos.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -5,7 +5,6 @@ quarkus.http.cors=true
# Allow large game uploads (DOS games can be 500MB+)
quarkus.http.limits.max-body-size=2048M
quarkus.resteasy-reactive.multipart.input-part.max-size=2048M
# ─── Jackson: snake_case to match frontend expectations ─
quarkus.jackson.property-naming-strategy=SNAKE_CASE
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -9,6 +9,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -136,6 +137,69 @@ class ExecutableDetectorTest {
assertFalse(ExecutableDetector.isLikelySelfExtractor(f));
}
@Test
void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception {
// Windows PE executable: MZ header + PE signature at offset 0x80
byte[] windowsExe = new byte[0x100];
windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ
windowsExe[0x3C] = (byte) 0x80; // PE offset at 0x80
windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature
Files.write(dir.resolve("WIN.EXE"), windowsExe);
// DOS executable: just MZ header, no PE
byte[] dosExe = new byte[0x40];
dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ
Files.write(dir.resolve("DOS.EXE"), dosExe);
String main = detector.findMain(dir);
assertNotNull(main);
assertTrue(main.endsWith("DOS.EXE"),
"DOS executable should be preferred over Windows, but got: " + main);
}
@Test
void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("readme.txt"), "text");
Files.writeString(dir.resolve("notes.md"), "markdown");
assertNull(detector.findMain(dir));
}
@Test
void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception {
// Self-extractor with PK\x03\x04 signature bytes (ZIP local header)
byte[] sfx = new byte[0x80];
sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header
sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04
Files.write(dir.resolve("SFX.EXE"), sfx);
// Real game executable
Files.write(dir.resolve("GAME.EXE"), createMZ(2000));
String main = detector.findMain(dir);
assertNotNull(main);
assertTrue(main.endsWith("GAME.EXE"),
"Self-extractor with PK signature bytes should be skipped, but got: " + main);
}
@Test
void findMain_handlesMixedContent(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("readme.txt"), "text");
Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped
Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized
Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win
Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com
String main = detector.findMain(dir);
assertNotNull(main);
assertTrue(main.endsWith("GAME.EXE"),
"Main game executable should be selected, but got: " + main);
}
@Test
void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception {
assertTrue(detector.findAll(dir).isEmpty());
}
private static byte[] createMZ(int size) {
byte[] buf = new byte[size];
buf[0] = 0x4D; // M
@@ -0,0 +1,94 @@
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class FileUtilsTest {
@Test
void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) {
Path nonExistent = dir.resolve("doesnotexist");
assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent));
}
@Test
void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("file1.txt"), "hello");
Files.writeString(dir.resolve("file2.txt"), "world");
Path sub = Files.createDirectories(dir.resolve("sub"));
Files.writeString(sub.resolve("nested.txt"), "deep");
assertTrue(Files.exists(dir.resolve("file1.txt")));
FileUtils.deleteDirectory(dir);
assertFalse(Files.exists(dir));
}
@Test
void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception {
Path a = Files.createDirectories(dir.resolve("a/b/c"));
Files.writeString(a.resolve("deep.txt"), "deep");
Path sub2 = Files.createDirectories(dir.resolve("sub2"));
Files.writeString(sub2.resolve("file.txt"), "data");
FileUtils.deleteDirectory(dir);
assertFalse(Files.exists(dir));
}
@Test
void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("game.exe"), "data");
Files.writeString(dir.resolve("helper.com"), "data");
Files.writeString(dir.resolve("run.bat"), "data");
Files.writeString(dir.resolve("readme.txt"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertEquals(3, result.size());
assertTrue(result.contains("game.exe"));
assertTrue(result.contains("helper.com"));
assertTrue(result.contains("run.bat"));
}
@Test
void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("GAME.EXE"), "data");
Files.writeString(dir.resolve("Helper.Com"), "data");
Files.writeString(dir.resolve("RUN.BAT"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertEquals(3, result.size());
}
@Test
void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception {
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
assertTrue(result.isEmpty());
}
@Test
void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("readme.txt"), "data");
Files.writeString(dir.resolve("notes.md"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertTrue(result.isEmpty());
}
@Test
void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception {
Path sub = Files.createDirectories(dir.resolve("subdir"));
Files.writeString(sub.resolve("game.exe"), "data");
Files.writeString(dir.resolve("root.exe"), "data");
List<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
assertEquals(2, result.size());
assertTrue(result.contains("root.exe"));
assertTrue(result.contains("subdir/game.exe"));
}
}
@@ -0,0 +1,291 @@
package org.dostalgia;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class GameServiceTest {
private static final ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(SerializationFeature.INDENT_OUTPUT);
// -- sanitizeId tests --
@Test
void sanitizeId_normalString_kept() {
assertEquals("hello", GameService.sanitizeId("hello"));
}
@Test
void sanitizeId_uppercaseToLowercase() {
assertEquals("hello", GameService.sanitizeId("HELLO"));
}
@Test
void sanitizeId_spacesToHyphens() {
assertEquals("hello-world", GameService.sanitizeId("hello world"));
}
@Test
void sanitizeId_specialCharsStripped() {
assertEquals("helloworld", GameService.sanitizeId("hello!@#$world"));
}
@Test
void sanitizeId_tripleHyphenStripped() {
assertEquals("test", GameService.sanitizeId("---test---"));
}
@Test
void sanitizeId_emptyReturnsUnknown() {
assertEquals("unknown", GameService.sanitizeId(""));
}
@Test
void sanitizeId_onlySpecialCharsReturnsUnknown() {
assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()"));
}
@Test
void sanitizeId_mixedCaseAndSpaces() {
assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth"));
}
@Test
void sanitizeId_underscoresToHyphens() {
assertEquals("my-game", GameService.sanitizeId("my_game"));
}
@Test
void sanitizeId_numbersPreserved() {
assertEquals("game2", GameService.sanitizeId("Game2"));
}
// -- gameDir / gameJsonPath tests --
@Test
void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Path dir = svc.gameDir("test-game");
assertEquals(tempDir.resolve("games/test-game"), dir);
}
@Test
void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Path path = svc.gameJsonPath("test-game");
assertEquals(tempDir.resolve("games/test-game/game.json"), path);
}
// -- init tests --
@Test
void init_createsDirectories(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertTrue(Files.isDirectory(tempDir.resolve("games")));
assertTrue(Files.isDirectory(tempDir.resolve("artwork")));
assertTrue(Files.isDirectory(tempDir.resolve("saves")));
}
// -- save / load tests --
@Test
void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Game game = new Game("test-game", "Test Game");
game.setYear(1995);
game.setGenre("FPS");
svc.save(game);
Game loaded = svc.load("test-game");
assertEquals("test-game", loaded.getId());
assertEquals("Test Game", loaded.getTitle());
assertEquals(1995, loaded.getYear());
assertEquals("FPS", loaded.getGenre());
assertNotNull(loaded.getCreatedAt());
assertNotNull(loaded.getUpdatedAt());
}
@Test
void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
Game game = new Game("game1", "Game One");
svc.save(game);
Thread.sleep(10); // ensure timestamp advances
game.setGenre("Adventure");
svc.save(game);
Game loaded = svc.load("game1");
assertNotNull(loaded.getUpdatedAt());
assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt())
|| loaded.getUpdatedAt().equals(loaded.getCreatedAt()),
"updatedAt should be >= createdAt");
}
@Test
void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent"));
}
// -- list tests --
@Test
void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
List<Game> games = svc.list();
assertTrue(games.isEmpty());
}
@Test
void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("game1", "Game One"));
svc.save(new Game("game2", "Game Two"));
List<Game> games = svc.list();
assertEquals(2, games.size());
}
@Test
void list_sortsByTitle(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("zzz", "Z Game"));
svc.save(new Game("aaa", "A Game"));
List<Game> games = svc.list();
assertEquals(2, games.size());
assertEquals("A Game", games.get(0).getTitle());
assertEquals("Z Game", games.get(1).getTitle());
}
// -- delete tests --
@Test
void delete_removesGameDir(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
svc.save(new Game("game1", "Game One"));
assertTrue(Files.exists(svc.gameDir("game1")));
svc.delete("game1");
assertFalse(Files.exists(svc.gameDir("game1")));
}
@Test
void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
assertDoesNotThrow(() -> svc.delete("nonexistent"));
}
@Test
void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception {
GameService svc = new GameService();
svc.dataDir = tempDir.toString();
svc.init();
// Create backward-compat files that delete should clean up
Files.writeString(tempDir.resolve("games/game1.jsdos"), "old");
Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup");
svc.delete("game1");
assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos")));
assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos")));
}
// -- JSON serialization tests --
@Test
void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception {
Game g = new Game("my-game", "My Game");
g.setIgdbCoverId("abc123");
g.setHasCover(true);
g.setHasScreenshots(false);
g.setIgdbId(42);
g.setBundleSize(1024L);
g.setSetupExe("setup.exe");
String json = MAPPER.writeValueAsString(g);
assertTrue(json.contains("\"igdb_cover_id\""));
assertTrue(json.contains("\"has_cover\""));
assertTrue(json.contains("\"has_screenshots\""));
assertTrue(json.contains("\"bundle_size\""));
assertTrue(json.contains("\"setup_exe\""));
assertFalse(json.contains("igdbCoverId"));
assertFalse(json.contains("hasCover"));
assertFalse(json.contains("hasScreenshots"));
assertFalse(json.contains("bundleSize"));
assertFalse(json.contains("setupExe"));
Game deserialized = MAPPER.readValue(json, Game.class);
assertEquals("my-game", deserialized.getId());
assertEquals("My Game", deserialized.getTitle());
assertEquals("abc123", deserialized.getIgdbCoverId());
assertTrue(deserialized.isHasCover());
assertFalse(deserialized.isHasScreenshots());
assertEquals(Integer.valueOf(42), deserialized.getIgdbId());
assertEquals(Long.valueOf(1024L), deserialized.getBundleSize());
assertEquals("setup.exe", deserialized.getSetupExe());
}
@Test
void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception {
Game g = new Game("ts-game", "Timestamp Test");
String json = MAPPER.writeValueAsString(g);
assertTrue(json.contains("\"created_at\""));
assertTrue(json.contains("\"updated_at\""));
Game deserialized = MAPPER.readValue(json, Game.class);
assertNotNull(deserialized.getCreatedAt());
assertNotNull(deserialized.getUpdatedAt());
}
}
+154
View File
@@ -0,0 +1,154 @@
package org.dostalgia;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class GameTest {
@Test
void noArgConstructor_setsNothing() {
Game g = new Game();
assertNull(g.getId());
assertNull(g.getTitle());
assertNull(g.getYear());
assertNull(g.getGenre());
assertNull(g.getDeveloper());
assertNull(g.getPublisher());
assertNull(g.getDescription());
assertNull(g.getRating());
assertNull(g.getBundleFile());
assertNull(g.getSetupExe());
assertNull(g.getBundleSize());
assertNull(g.getExecutable());
assertNull(g.getExecutables());
assertNull(g.getPlatform());
assertNull(g.getIgdbId());
assertNull(g.getIgdbCoverId());
assertFalse(g.isHasCover());
assertFalse(g.isHasScreenshots());
assertNull(g.getScreenshots());
assertNull(g.getVideos());
assertFalse(g.isReady());
assertNull(g.getCreatedAt());
assertNull(g.getUpdatedAt());
}
@Test
void twoArgConstructor_setsIdTitleAndTimestamps() {
Game g = new Game("test-id", "Test Game");
assertEquals("test-id", g.getId());
assertEquals("Test Game", g.getTitle());
assertFalse(g.isReady());
assertNotNull(g.getCreatedAt());
assertNotNull(g.getUpdatedAt());
}
@Test
void settersAndGetters_roundTrip() {
Game g = new Game();
g.setId("id1");
g.setTitle("Title");
g.setYear(1995);
g.setGenre("Action");
g.setDeveloper("DevCo");
g.setPublisher("PubCo");
g.setDescription("A great game");
g.setRating(4.5);
g.setBundleFile("game.zip");
g.setSetupExe("setup.exe");
g.setBundleSize(12345L);
g.setExecutable("game.exe");
g.setExecutables(List.of("game.exe", "setup.exe"));
g.setPlatform("dos");
g.setIgdbId(42);
g.setIgdbCoverId("abc123");
g.setHasCover(true);
g.setHasScreenshots(true);
g.setScreenshots(List.of("shot1.png"));
g.setVideos(List.of("vid1"));
g.setReady(true);
Instant now = Instant.now();
g.setCreatedAt(now);
g.setUpdatedAt(now);
assertEquals("id1", g.getId());
assertEquals("Title", g.getTitle());
assertEquals(1995, g.getYear());
assertEquals("Action", g.getGenre());
assertEquals("DevCo", g.getDeveloper());
assertEquals("PubCo", g.getPublisher());
assertEquals("A great game", g.getDescription());
assertEquals(4.5, g.getRating());
assertEquals("game.zip", g.getBundleFile());
assertEquals("setup.exe", g.getSetupExe());
assertEquals(12345L, g.getBundleSize());
assertEquals("game.exe", g.getExecutable());
assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables());
assertEquals("dos", g.getPlatform());
assertEquals(42, g.getIgdbId());
assertEquals("abc123", g.getIgdbCoverId());
assertTrue(g.isHasCover());
assertTrue(g.isHasScreenshots());
assertEquals(List.of("shot1.png"), g.getScreenshots());
assertEquals(List.of("vid1"), g.getVideos());
assertTrue(g.isReady());
assertEquals(now, g.getCreatedAt());
assertEquals(now, g.getUpdatedAt());
}
@Test
void isHasSetup_nullSetupExe_returnsFalse() {
Game g = new Game();
g.setSetupExe(null);
assertFalse(g.isHasSetup());
}
@Test
void isHasSetup_blankSetupExe_returnsFalse() {
Game g = new Game();
g.setSetupExe(" ");
assertFalse(g.isHasSetup());
}
@Test
void isHasSetup_nonBlankSetupExe_returnsTrue() {
Game g = new Game();
g.setSetupExe("setup.exe");
assertTrue(g.isHasSetup());
}
@Test
void isPlayable_notReady_returnsFalse() {
Game g = new Game();
g.setReady(false);
assertFalse(g.isPlayable());
}
@Test
void isPlayable_readyNullPlatform_returnsTrue() {
Game g = new Game();
g.setReady(true);
g.setPlatform(null);
assertTrue(g.isPlayable());
}
@Test
void isPlayable_readyDosPlatform_returnsTrue() {
Game g = new Game();
g.setReady(true);
g.setPlatform("dos");
assertTrue(g.isPlayable());
}
@Test
void isPlayable_readyWindowsPlatform_returnsFalse() {
Game g = new Game();
g.setReady(true);
g.setPlatform("windows");
assertFalse(g.isPlayable());
}
}
@@ -0,0 +1,265 @@
package org.dostalgia;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class IgdbServiceTest {
private IgdbService service;
private ObjectMapper mapper;
private Method epochToYearMethod;
private Method extractGenresMethod;
private Method sanitizeMethod;
private Method jsonNodeToMapMethod;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() throws Exception {
service = new IgdbService();
service.clientId = Optional.of("test-client-id");
service.clientSecret = Optional.of("test-client-secret");
mapper = new ObjectMapper();
epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class);
epochToYearMethod.setAccessible(true);
extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class);
extractGenresMethod.setAccessible(true);
sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class);
sanitizeMethod.setAccessible(true);
jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class);
jsonNodeToMapMethod.setAccessible(true);
}
// -- isConfigured tests --
@Test
void isConfigured_returnsTrueWhenBothPresentAndNonBlank() {
assertTrue(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientIdEmpty() {
service.clientId = Optional.empty();
assertFalse(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientSecretEmpty() {
service.clientSecret = Optional.empty();
assertFalse(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientIdBlank() {
service.clientId = Optional.of("");
assertFalse(service.isConfigured());
}
@Test
void isConfigured_returnsFalseWhenClientSecretBlank() {
service.clientSecret = Optional.of("");
assertFalse(service.isConfigured());
}
// -- epochToYear tests --
@Test
void epochToYear_withReleaseDate_returnsYear() throws Exception {
// 946684800 = 2000-01-01T00:00:00Z
JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertTrue(year.isPresent());
int y = year.get();
assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y);
}
@Test
void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception {
JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertFalse(year.isPresent());
}
@Test
void epochToYear_withEpochZero_returnsYear() throws Exception {
// epoch 0 = 1970-01-01T00:00:00Z
JsonNode node = mapper.readTree("{\"first_release_date\": 0}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertTrue(year.isPresent());
assertEquals(1970, year.get());
}
@Test
void epochToYear_negativeEpoch_returnsYear() throws Exception {
// negative epoch is before 1970
JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}");
Optional<Integer> year = (Optional<Integer>) epochToYearMethod.invoke(null, node);
assertTrue(year.isPresent());
// Calendar may handle negative values differently; just verify it returns something
assertNotNull(year.get());
}
// -- extractGenres tests --
@Test
void extractGenres_withGenresArray_returnsList() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertEquals(2, genres.size());
assertTrue(genres.contains("Action"));
assertTrue(genres.contains("Adventure"));
}
@Test
void extractGenres_emptyArray_returnsEmptyList() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": []}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertTrue(genres.isEmpty());
}
@Test
void extractGenres_missingField_returnsEmptyList() throws Exception {
JsonNode node = mapper.readTree("{\"name\": \"Test\"}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertTrue(genres.isEmpty());
}
@Test
void extractGenres_genreWithoutName_skipsEntry() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertEquals(1, genres.size());
assertEquals("Action", genres.get(0));
}
// -- sanitize tests --
@Test
void sanitize_escapesBackslashes() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "path\\to\\file");
assertEquals("path\\\\to\\\\file", result);
}
@Test
void sanitize_escapesQuotes() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "say \"hello\"");
assertEquals("say \\\"hello\\\"", result);
}
@Test
void sanitize_escapesBothBackslashesAndQuotes() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\"");
assertEquals("\\\\\\\"mixed\\\\\\\"", result);
}
@Test
void sanitize_normalString_unchanged() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "hello world");
assertEquals("hello world", result);
}
@Test
void sanitize_emptyString_unchanged() throws Exception {
String result = (String) sanitizeMethod.invoke(service, "");
assertEquals("", result);
}
// -- jsonNodeToMap tests --
@Test
void jsonNodeToMap_basicFields() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals(123, map.get("igdb_id"));
assertEquals("Test Game", map.get("name"));
}
@Test
void jsonNodeToMap_withGenres() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertTrue(map.containsKey("genres"));
assertEquals(List.of("RPG"), map.get("genres"));
}
@Test
void jsonNodeToMap_withInvolvedCompanies() throws Exception {
JsonNode node = mapper.readTree("""
{
"id": 1,
"name": "G",
"involved_companies": [
{ "company": { "name": "DevStudio" }, "developer": true, "publisher": false },
{ "company": { "name": "PubStudio" }, "developer": false, "publisher": true }
]
}
""");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("DevStudio", map.get("developer"));
assertEquals("PubStudio", map.get("publisher"));
}
@Test
void jsonNodeToMap_withSummary() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("A great game", map.get("summary"));
}
@Test
void jsonNodeToMap_withCoverUrl() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
String coverUrl = (String) map.get("cover_url");
assertNotNull(coverUrl);
assertTrue(coverUrl.startsWith("https:"));
assertTrue(coverUrl.contains("t_cover_big"));
}
@Test
void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception {
JsonNode node = mapper.readTree("{}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals(0, map.get("igdb_id"));
assertEquals("", map.get("name"));
}
@Test
void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception {
JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertFalse(map.containsKey("developer"));
assertFalse(map.containsKey("publisher"));
}
@Test
void jsonNodeToMap_firstDevAndPubOnly() throws Exception {
// Multiple developers/publishers — only first of each should be recorded
JsonNode node = mapper.readTree("""
{
"id": 1, "name": "G",
"involved_companies": [
{ "company": { "name": "FirstDev" }, "developer": true, "publisher": false },
{ "company": { "name": "SecondDev" }, "developer": true, "publisher": false },
{ "company": { "name": "FirstPub" }, "developer": false, "publisher": true }
]
}
""");
Map<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("FirstDev", map.get("developer"));
assertEquals("FirstPub", map.get("publisher"));
}
}
@@ -1,4 +1,4 @@
package com.dostalgia;
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -0,0 +1,198 @@
package org.dostalgia;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class StaticResourceTest {
@Test
void health_returnsOkStatusAndVersion(@TempDir Path tempDir) {
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.health();
assertEquals(200, resp.getStatus());
Object entity = resp.getEntity();
assertInstanceOf(Map.class, entity);
Map<?, ?> map = (Map<?, ?>) entity;
assertEquals("ok", map.get("status"));
assertEquals("0.1.1", map.get("version"));
}
@Test
void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("test.jsdos"), "zip content");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("test.jsdos");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/zip", resp.getMediaType().toString());
}
@Test
void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("archive.zip"), "zip content");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("archive.zip");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/zip", resp.getMediaType().toString());
}
@Test
void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("cover.jpg"), "image data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("cover.jpg");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/jpeg", resp.getMediaType().toString());
}
@Test
void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("photo.jpeg"), "image data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("photo.jpeg");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/jpeg", resp.getMediaType().toString());
}
@Test
void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("screenshot.png"), "png data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("screenshot.png");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/png", resp.getMediaType().toString());
}
@Test
void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("image.webp"), "webp data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("image.webp");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/webp", resp.getMediaType().toString());
}
@Test
void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("anim.gif"), "gif data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("anim.gif");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/gif", resp.getMediaType().toString());
}
@Test
void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("data.json"), "{}");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("data.json");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/json", resp.getMediaType().toString());
}
@Test
void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception {
Path gamesDir = Files.createDirectories(tempDir.resolve("games"));
Files.writeString(gamesDir.resolve("game.exe"), "MZ");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("game.exe");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("application/octet-stream", resp.getMediaType().toString());
}
@Test
void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) {
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("../etc/passwd");
assertEquals(403, resp.getStatus());
}
@Test
void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("games"));
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getGameFile("nonexistent.exe");
assertEquals(404, resp.getStatus());
}
@Test
void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception {
Path artworkDir = Files.createDirectories(tempDir.resolve("artwork"));
Files.writeString(artworkDir.resolve("cover.jpg"), "image data");
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getArtwork("cover.jpg");
assertEquals(200, resp.getStatus());
assertNotNull(resp.getMediaType());
assertEquals("image/jpeg", resp.getMediaType().toString());
assertTrue(resp.getHeaders().containsKey("Cache-Control"));
}
@Test
void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) {
StaticResource sr = new StaticResource();
sr.dataDir = tempDir.toString();
Response resp = sr.getArtwork("../../etc/passwd");
assertEquals(403, resp.getStatus());
}
}
@@ -0,0 +1,230 @@
package org.dostalgia;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class ZipServiceTest {
private final ZipService zip = new ZipService();
// Wire the config dependency manually (no CDI)
{
zip.config = new ConfigBuilder();
}
@Test
void unzip_extractsFiles(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("game.exe"));
zos.write("MZ".getBytes());
zos.closeEntry();
zos.putNextEntry(new ZipEntry("readme.txt"));
zos.write("Hello".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
assertTrue(Files.exists(dest.resolve("game.exe")));
assertTrue(Files.exists(dest.resolve("readme.txt")));
assertEquals("MZ", Files.readString(dest.resolve("game.exe")));
assertEquals("Hello", Files.readString(dest.resolve("readme.txt")));
}
@Test
void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("subdir/game.exe"));
zos.write("MZ".getBytes());
zos.closeEntry();
zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt"));
zos.write("deep".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
assertTrue(Files.exists(dest.resolve("subdir/game.exe")));
assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt")));
}
@Test
void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
Files.createDirectories(dest);
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("../evil.txt"));
zos.write("malicious".getBytes());
zos.closeEntry();
zos.putNextEntry(new ZipEntry("good.txt"));
zos.write("good".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
// Malicious entry should be skipped (path traversal)
assertFalse(Files.exists(dir.resolve("evil.txt")));
// Good entry should be extracted
assertTrue(Files.exists(dest.resolve("good.txt")));
}
@Test
void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception {
Path zipFile = dir.resolve("test.zip");
Path dest = dir.resolve("output");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
zos.putNextEntry(new ZipEntry("adir/"));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("adir/file.txt"));
zos.write("data".getBytes());
zos.closeEntry();
}
zip.unzip(zipFile, dest);
assertTrue(Files.isDirectory(dest.resolve("adir")));
assertTrue(Files.exists(dest.resolve("adir/file.txt")));
}
@Test
void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception {
Files.createDirectories(dir.resolve("sub1"));
Files.createDirectories(dir.resolve("sub2"));
Files.writeString(dir.resolve("sub1/file.txt"), "data");
zip.flattenSingleDir(dir);
assertTrue(Files.exists(dir.resolve("sub1/file.txt")));
assertTrue(Files.isDirectory(dir.resolve("sub1")));
assertTrue(Files.isDirectory(dir.resolve("sub2")));
}
@Test
void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception {
Files.writeString(dir.resolve("file.txt"), "data");
Files.writeString(dir.resolve("other.txt"), "data");
zip.flattenSingleDir(dir);
assertTrue(Files.exists(dir.resolve("file.txt")));
assertTrue(Files.exists(dir.resolve("other.txt")));
}
@Test
void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception {
Path single = Files.createDirectories(dir.resolve("single"));
Files.createDirectories(single.resolve("nested"));
Files.writeString(single.resolve("file.txt"), "data");
Files.writeString(single.resolve("nested/deep.txt"), "deep");
zip.flattenSingleDir(dir);
// Files should now be directly under dir
assertTrue(Files.exists(dir.resolve("file.txt")));
assertTrue(Files.exists(dir.resolve("nested/deep.txt")));
// The intermediate single dir should be gone
assertFalse(Files.exists(single));
}
@Test
void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception {
zip.flattenSingleDir(dir);
assertTrue(Files.exists(dir));
}
@Test
void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception {
Path extractDir = dir.resolve("extract");
Files.createDirectories(extractDir.resolve("sub"));
Files.writeString(extractDir.resolve("game.exe"), "MZ");
Files.writeString(extractDir.resolve("sub/data.bin"), "binary");
Files.writeString(extractDir.resolve("readme.txt"), "info");
Path bundlePath = dir.resolve("output.jsdos");
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
assertTrue(Files.exists(bundlePath));
assertTrue(Files.size(bundlePath) > 0);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
boolean hasGameExe = false;
boolean hasJsdosDir = false;
boolean hasJsdosConf = false;
boolean hasJsdosJson = false;
boolean hasDataBin = false;
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
if (name.equals("game.exe")) hasGameExe = true;
if (name.startsWith(".jsdos/")) hasJsdosDir = true;
if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true;
if (name.equals("sub/data.bin")) hasDataBin = true;
zis.closeEntry();
}
assertTrue(hasGameExe, "Should contain game.exe");
assertTrue(hasJsdosDir, "Should contain .jsdos/ entries");
assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf");
assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json");
assertTrue(hasDataBin, "Should contain sub/data.bin");
}
}
@Test
void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception {
Path extractDir = dir.resolve("extract");
Files.createDirectories(extractDir);
Files.writeString(extractDir.resolve("game.exe"), "MZ");
Files.writeString(extractDir.resolve("cdimage.nrg"), "image");
Files.writeString(extractDir.resolve("cdimage.mdf"), "image");
Path bundlePath = dir.resolve("output.jsdos");
zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String name = entry.getName();
assertNotEquals("cdimage.nrg", name, "Should not contain .nrg");
assertNotEquals("cdimage.mdf", name, "Should not contain .mdf");
zis.closeEntry();
}
}
}
@Test
void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception {
Path extractDir = dir.resolve("extract");
Files.createDirectories(extractDir);
Files.writeString(extractDir.resolve("somefile.bin"), "data");
Path bundlePath = dir.resolve("output.jsdos");
// null exePath triggers CD-only config generation
zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) {
boolean hasJsdosConf = false;
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true;
zis.closeEntry();
}
assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf");
}
}
}