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,399 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dosboxConfTemplate = `[autoexec]
|
||||
@echo off
|
||||
mount c .
|
||||
c:
|
||||
cls
|
||||
%s
|
||||
`
|
||||
|
||||
// handleUploadGame handles POST /api/upload — receives a ZIP, creates a .jsdos bundle.
|
||||
func handleUploadGame(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form (max 500MB)
|
||||
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
||||
http.Error(w, "Failed to parse upload: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "No file provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
title := r.FormValue("title")
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
}
|
||||
|
||||
gameID := sanitizeID(title)
|
||||
|
||||
// Ensure uniqueness
|
||||
baseID := gameID
|
||||
counter := 2
|
||||
for {
|
||||
if _, err := loadGame(gameID); err != nil {
|
||||
break // not found, we can use this ID
|
||||
}
|
||||
gameID = fmt.Sprintf("%s-%d", baseID, counter)
|
||||
counter++
|
||||
}
|
||||
|
||||
// Save uploaded ZIP to temp file
|
||||
tmpDir, err := os.MkdirTemp("", "dostalgia-upload-")
|
||||
if err != nil {
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
zipPath := filepath.Join(tmpDir, header.Filename)
|
||||
dst, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
dst.Close()
|
||||
http.Error(w, "Failed to save upload", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dst.Close()
|
||||
|
||||
// Extract ZIP
|
||||
extractDir := filepath.Join(tmpDir, "extracted")
|
||||
if err := os.MkdirAll(extractDir, 0755); err != nil {
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := extractZip(zipPath, extractDir); err != nil {
|
||||
http.Error(w, "Invalid ZIP file: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Find main executable
|
||||
mainExe := findMainExe(extractDir)
|
||||
if mainExe == "" {
|
||||
http.Error(w, "No executable (.exe/.com/.bat) found in the archive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine size and strategy
|
||||
sizeMB := dirSizeMB(extractDir)
|
||||
bundleType := "standard"
|
||||
if sizeMB > 80 {
|
||||
bundleType = "sockdrive"
|
||||
}
|
||||
|
||||
// Set up game directory
|
||||
gameDirPath := gameDir(gameID)
|
||||
if err := os.MkdirAll(gameDirPath, 0755); err != nil {
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var bundleFile string
|
||||
if bundleType == "sockdrive" {
|
||||
// Copy all files loose
|
||||
if err := copyDir(extractDir, gameDirPath); err != nil {
|
||||
http.Error(w, "Failed to store game files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Create dosbox.conf
|
||||
confDir := filepath.Join(gameDirPath, ".jsdos")
|
||||
os.MkdirAll(confDir, 0755)
|
||||
relExe, _ := filepath.Rel(extractDir, mainExe)
|
||||
os.WriteFile(filepath.Join(confDir, "dosbox.conf"),
|
||||
[]byte(fmt.Sprintf(dosboxConfTemplate, relExe)), 0644)
|
||||
bundleFile = ".jsdos/dosbox.conf"
|
||||
} else {
|
||||
// Create .jsdos ZIP bundle
|
||||
bundleFile = gameID + ".jsdos"
|
||||
bundlePath := filepath.Join(gameDirPath, bundleFile)
|
||||
if err := createJSDOSBundle(extractDir, mainExe, bundlePath); err != nil {
|
||||
http.Error(w, "Failed to create bundle: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
game := &Game{
|
||||
ID: gameID,
|
||||
Title: title,
|
||||
BundleType: bundleType,
|
||||
BundleFile: bundleFile,
|
||||
Ready: true,
|
||||
}
|
||||
if err := saveGame(game); err != nil {
|
||||
http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, game)
|
||||
}
|
||||
|
||||
// extractZip extracts a ZIP file to the given directory.
|
||||
func extractZip(zipPath, dest string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
// Prevent path traversal
|
||||
target := filepath.Join(dest, f.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(target, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := os.Create(target)
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(dst, src)
|
||||
src.Close()
|
||||
dst.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mainExeCandidate is used for sorting executables by relevance.
|
||||
type mainExeCandidate struct {
|
||||
depth int
|
||||
isInstaller bool
|
||||
size int64
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
// findMainExe finds the most likely main executable in an extracted game directory.
|
||||
func findMainExe(dir string) string {
|
||||
var candidates []mainExeCandidate
|
||||
|
||||
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(d.Name()))
|
||||
if ext != ".exe" && ext != ".com" && ext != ".bat" {
|
||||
return nil
|
||||
}
|
||||
info, _ := d.Info()
|
||||
name := strings.ToLower(d.Name())
|
||||
isInstaller := strings.Contains(name, "install") ||
|
||||
strings.Contains(name, "setup") ||
|
||||
strings.Contains(name, "config")
|
||||
depth := strings.Count(path[len(dir):], string(os.PathSeparator))
|
||||
candidates = append(candidates, mainExeCandidate{
|
||||
depth: depth,
|
||||
isInstaller: isInstaller,
|
||||
size: info.Size(),
|
||||
name: name,
|
||||
path: path,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Sort: shallow depth, non-installer, larger files first
|
||||
sortCandidates(candidates)
|
||||
return candidates[0].path
|
||||
}
|
||||
|
||||
func sortCandidates(c []mainExeCandidate) {
|
||||
for i := 0; i < len(c); i++ {
|
||||
for j := i + 1; j < len(c); j++ {
|
||||
// Compare: depth (lower first), installer (false first), size (larger first)
|
||||
swap := false
|
||||
if c[i].depth != c[j].depth {
|
||||
swap = c[i].depth > c[j].depth
|
||||
} else if c[i].isInstaller != c[j].isInstaller {
|
||||
swap = c[i].isInstaller
|
||||
} else {
|
||||
swap = c[i].size < c[j].size
|
||||
}
|
||||
if swap {
|
||||
c[i], c[j] = c[j], c[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dirSizeMB returns the total size of a directory in megabytes.
|
||||
func dirSizeMB(dir string) float64 {
|
||||
var total int64
|
||||
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, _ := d.Info()
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return float64(total) / (1024 * 1024)
|
||||
}
|
||||
|
||||
// copyDir copies a directory recursively.
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(src, path)
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0755)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, 0644)
|
||||
})
|
||||
}
|
||||
|
||||
// createJSDOSBundle creates a .jsdos bundle ZIP from extracted game files.
|
||||
func createJSDOSBundle(extractDir, mainExe, bundlePath string) error {
|
||||
// Create a temp directory for bundling
|
||||
tmpDir, err := os.MkdirTemp("", "dostalgia-bundle-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Copy game files
|
||||
if err := copyDir(extractDir, tmpDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create .jsdos config
|
||||
jsdosDir := filepath.Join(tmpDir, ".jsdos")
|
||||
if err := os.MkdirAll(jsdosDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relExe, _ := filepath.Rel(extractDir, mainExe)
|
||||
conf := fmt.Sprintf(dosboxConfTemplate, relExe)
|
||||
if err := os.WriteFile(filepath.Join(jsdosDir, "dosbox.conf"), []byte(conf), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the ZIP
|
||||
zf, err := os.Create(bundlePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zf.Close()
|
||||
|
||||
zw := zip.NewWriter(zf)
|
||||
defer zw.Close()
|
||||
|
||||
return filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(tmpDir, path)
|
||||
w, err := zw.Create(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// handleUploadCover handles POST /api/games/{id}/cover — upload cover art.
|
||||
func handleUploadCover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
if _, err := loadGame(id); err != nil {
|
||||
http.Error(w, "Game not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
http.Error(w, "File too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "No file provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Determine extension
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
||||
http.Error(w, "Only JPG, PNG, or WebP images accepted", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
coverPath := filepath.Join(gameDir(id), "cover"+ext)
|
||||
// Remove old cover files
|
||||
for _, oldExt := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
os.Remove(filepath.Join(gameDir(id), "cover"+oldExt))
|
||||
}
|
||||
|
||||
dst, err := os.Create(coverPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
http.Error(w, "Failed to save cover", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "path": coverPath})
|
||||
}
|
||||
|
||||
var _ = time.Now // keep import
|
||||
Reference in New Issue
Block a user