Initial commit: Dostalgia — DOS game hub
- Go backend (single binary) with embedded Svelte frontend - JSON-per-game storage (no database) - .jsdos bundle creation on upload - Dark phosphor-green theme (#33FF33) - js-dos v8 emulator integration - Sockdrive support for games >80MB - IGDB placeholder for future artwork scraping - Docker deployment ready
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
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 entire game directory
|
||||
if err := os.RemoveAll(gameDir(g.ID)); err != nil {
|
||||
http.Error(w, "Failed to delete game", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user