545e2d2bb5
- .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
400 lines
9.6 KiB
Go
400 lines
9.6 KiB
Go
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 flat in data/games/
|
|
bundleFile = gameID + ".jsdos"
|
|
bundlePath := filepath.Join(filepath.Dir(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
|