Files
dostalgia/main.go
T
David Alvarez 545e2d2bb5 Fix Play button 404: bundle path and field name
- .jsdos bundles saved flat in data/games/ so /games/{id}.jsdos serves them
- bundleUrl() uses game.id (matches JSON) instead of game.slug (undefined)
- Delete handler also removes flat .jsdos bundle on game deletion
2026-05-23 18:38:53 +02:00

311 lines
8.0 KiB
Go

package main
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
//go:embed all:frontend/dist
var frontendFS embed.FS
var (
dataDir string
devMode bool
)
func main() {
// Determine data directory
dataDir = os.Getenv("DOSTALGIA_DATA")
if dataDir == "" {
dataDir = "data"
}
devMode = os.Getenv("DOSTALGIA_DEV") == "1"
// Ensure data directories exist
for _, d := range []string{"games", "artwork", "saves"} {
os.MkdirAll(filepath.Join(dataDir, d), 0755)
}
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/health", handleHealth)
mux.HandleFunc("/api/games", handleGames)
mux.HandleFunc("/api/games/", handleGameByID)
mux.HandleFunc("/api/upload", handleUploadGame)
mux.HandleFunc("/api/igdb/search", handleIGDBSearch)
// Serve game bundles and artwork as static files
gameFS := http.FileServer(http.Dir(filepath.Join(dataDir, "games")))
mux.Handle("/games/", http.StripPrefix("/games/", gameFS))
artFS := http.FileServer(http.Dir(filepath.Join(dataDir, "artwork")))
mux.Handle("/artwork/", http.StripPrefix("/artwork/", artFS))
// Sockdrive file server
mux.HandleFunc("/api/sockdrive/", handleSockdrive)
// Frontend SPA — serve embedded files, fall back to index.html
mux.HandleFunc("/", handleFrontend)
port := os.Getenv("PORT")
if port == "" {
port = "8765"
}
log.Printf("🎮 Dostalgia starting on :%s (data: %s)", port, dataDir)
if devMode {
log.Println("⚠️ DEV mode — run Vite dev server alongside for hot reload")
}
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}
// ─── API Handlers ────────────────────────────────────────────────
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"version": "0.1.0",
})
}
func handleGames(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
games, err := listGames()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"games": games,
"total": len(games),
})
case http.MethodPost:
// Create a game manually (without upload)
var g Game
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if g.Title == "" {
http.Error(w, "Title is required", http.StatusBadRequest)
return
}
if g.ID == "" {
g.ID = sanitizeID(g.Title)
}
if err := saveGame(&g); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, g)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleGameByID(w http.ResponseWriter, r *http.Request) {
// Extract ID from /api/games/{id} or /api/games/{id}/...
path := strings.TrimPrefix(r.URL.Path, "/api/games/")
parts := strings.SplitN(path, "/", 2)
id := parts[0]
if id == "" {
http.Error(w, "Game ID required", http.StatusBadRequest)
return
}
// Cover upload sub-endpoint
if len(parts) > 1 && parts[1] == "cover" {
handleUploadCover(w, r)
return
}
switch r.Method {
case http.MethodGet:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, g)
case http.MethodPatch:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Apply simple string/number fields
if v, ok := updates["title"].(string); ok {
g.Title = v
}
if v, ok := updates["year"].(float64); ok {
g.Year = int(v)
}
if v, ok := updates["genre"].(string); ok {
g.Genre = v
}
if v, ok := updates["developer"].(string); ok {
g.Developer = v
}
if v, ok := updates["publisher"].(string); ok {
g.Publisher = v
}
if v, ok := updates["description"].(string); ok {
g.Description = v
}
if v, ok := updates["rating"].(float64); ok {
g.Rating = v
}
if err := saveGame(g); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, g)
case http.MethodDelete:
g, err := loadGame(id)
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
// Remove the game directory (game.json, cover, sockdrive files)
os.RemoveAll(gameDir(g.ID))
// Also remove the flat .jsdos bundle if it exists
bundlePath := filepath.Join(dataDir, "games", g.ID+".jsdos")
os.Remove(bundlePath)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// ─── Sockdrive File Server ──────────────────────────────────────
func handleSockdrive(w http.ResponseWriter, r *http.Request) {
// /api/sockdrive/{slug}/file?path=...
path := strings.TrimPrefix(r.URL.Path, "/api/sockdrive/")
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
slug := parts[0]
if parts[1] == "file" {
filePath := r.URL.Query().Get("path")
if filePath == "" {
http.Error(w, "path query parameter required", http.StatusBadRequest)
return
}
fullPath := filepath.Join(gameDir(slug), filePath)
// Prevent traversal
fullPath = filepath.Clean(fullPath)
if !strings.HasPrefix(fullPath, filepath.Clean(gameDir(slug))) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, fullPath)
return
}
if parts[1] == "files" {
// List all files in game directory
var files []map[string]interface{}
gameDirPath := gameDir(slug)
filepath.WalkDir(gameDirPath, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, _ := d.Info()
rel, _ := filepath.Rel(gameDirPath, path)
files = append(files, map[string]interface{}{
"path": strings.ReplaceAll(rel, "\\", "/"),
"size": info.Size(),
})
return nil
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"files": files,
"total": len(files),
})
return
}
http.Error(w, "Not found", http.StatusNotFound)
}
// ─── Frontend SPA ───────────────────────────────────────────────
func handleFrontend(w http.ResponseWriter, r *http.Request) {
// Don't intercept API or static routes
if strings.HasPrefix(r.URL.Path, "/api/") ||
strings.HasPrefix(r.URL.Path, "/games/") ||
strings.HasPrefix(r.URL.Path, "/artwork/") {
http.NotFound(w, r)
return
}
var content fs.FS
var err error
if devMode {
content = os.DirFS("frontend/dist")
} else {
content, err = fs.Sub(frontendFS, "frontend/dist")
if err != nil {
http.Error(w, "Frontend not built", http.StatusServiceUnavailable)
return
}
}
// Try to serve the exact file
filePath := strings.TrimPrefix(r.URL.Path, "/")
if filePath == "" {
filePath = "index.html"
}
f, err := content.Open(filePath)
if err == nil {
f.Close()
http.FileServer(http.FS(content)).ServeHTTP(w, r)
return
}
// Fallback to index.html for SPA routing
indexData, err := fs.ReadFile(content, "index.html")
if err != nil {
http.Error(w, "Frontend not built. Run 'cd frontend && npm run build'", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexData)
}
// ─── Helpers ────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}