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) }